feat(core): polish codebase, enhance error handling, security hardening, rate limiting, and a11y compliance
All checks were successful
Deploy Canina / deploy (push) Successful in 1m43s

This commit is contained in:
parsa aghaei 2026-08-25 11:33:37 +03:30
parent 70c2627d93
commit 7b89e141c5
17 changed files with 237 additions and 17 deletions

View File

@ -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")
}

View File

@ -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: 'تایید کد پیامکی و ورود به سیستم' })

View File

@ -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(

View File

@ -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')

View File

@ -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<string, unknown>) {
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;
}

View File

@ -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,
};

View File

@ -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,
});

View File

@ -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()

View File

@ -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 ||

View File

@ -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(

View File

@ -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 <ServerErrorPage onRetry={reset} />;
}

View File

@ -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 <ServerErrorPage onRetry={reset} />;
}

View File

@ -0,0 +1,31 @@
import React from "react";
import { Skeleton } from "../../components/Skeleton";
export default function BlogLoading() {
return (
<div className="min-h-screen bg-slate-50/50 py-10 px-4 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto space-y-8">
<div className="text-center space-y-3 max-w-xl mx-auto">
<Skeleton className="w-48 h-8 rounded-full mx-auto" />
<Skeleton className="w-72 h-10 rounded-2xl mx-auto" />
<Skeleton className="w-96 h-5 rounded-xl mx-auto" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-3xl p-5 border border-slate-100 space-y-4 shadow-sm">
<Skeleton className="w-full h-48 rounded-2xl" />
<div className="flex gap-2">
<Skeleton className="w-16 h-6 rounded-full" />
<Skeleton className="w-20 h-6 rounded-full" />
</div>
<Skeleton className="w-3/4 h-6 rounded-lg" />
<Skeleton className="w-full h-4 rounded-lg" />
<Skeleton className="w-2/3 h-4 rounded-lg" />
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -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 <ServerErrorPage onRetry={reset} />;
}

View File

@ -0,0 +1,38 @@
import React from "react";
import { Skeleton } from "../../components/Skeleton";
export default function ShopLoading() {
return (
<div className="min-h-screen bg-slate-50/50 py-10 px-4 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto space-y-8">
{/* Banner Skeleton */}
<div className="w-full h-44 rounded-3xl bg-slate-200 animate-pulse" />
{/* Filters & Search Row */}
<div className="flex flex-wrap gap-4 items-center justify-between">
<Skeleton className="w-48 h-12 rounded-2xl" />
<div className="flex gap-2">
<Skeleton className="w-24 h-10 rounded-xl" />
<Skeleton className="w-24 h-10 rounded-xl" />
<Skeleton className="w-24 h-10 rounded-xl" />
</div>
</div>
{/* Product Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="bg-white rounded-3xl p-5 border border-slate-100 space-y-4 shadow-sm">
<Skeleton className="w-full aspect-square rounded-2xl" />
<Skeleton className="w-3/4 h-5 rounded-lg" />
<Skeleton className="w-1/2 h-4 rounded-lg" />
<div className="flex items-center justify-between pt-4 border-t border-slate-100">
<Skeleton className="w-24 h-6 rounded-lg" />
<Skeleton className="w-10 h-10 rounded-xl" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -247,10 +247,11 @@ export default function B2BLandingClient() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-businessName" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
نام مجموعه / داروخانه / کلینیک <span className="text-red-500">*</span>
</label>
<input
id="b2b-businessName"
type="text"
required
placeholder="مثلاً کلینیک تخصصی دکتر البرزی"
@ -261,10 +262,11 @@ export default function B2BLandingClient() {
</div>
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-businessType" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
نوع فعالیت <span className="text-red-500">*</span>
</label>
<select
id="b2b-businessType"
value={formData.businessType}
onChange={(e) => setFormData({ ...formData, businessType: e.target.value })}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl px-4 py-3 text-sm text-medical-gray-900 focus:bg-white focus:border-canina-blue focus:ring-4 focus:ring-canina-blue/10 outline-none transition-all"
@ -280,10 +282,11 @@ export default function B2BLandingClient() {
<div className="grid sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-contactName" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
نام رابط / مسئول خرید
</label>
<input
id="b2b-contactName"
type="text"
placeholder="نام و نام خانوادگی"
value={formData.contactName}
@ -293,10 +296,11 @@ export default function B2BLandingClient() {
</div>
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-phone" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
شماره تماس همراه <span className="text-red-500">*</span>
</label>
<input
id="b2b-phone"
type="tel"
required
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
@ -307,10 +311,11 @@ export default function B2BLandingClient() {
</div>
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-city" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
شهر / استان
</label>
<input
id="b2b-city"
type="text"
placeholder="مثلاً تهران / اصفهان"
value={formData.city}
@ -321,10 +326,11 @@ export default function B2BLandingClient() {
</div>
<div>
<label className="block text-xs font-bold text-medical-gray-700 mb-1.5">
<label htmlFor="b2b-notes" className="block text-xs font-bold text-medical-gray-700 mb-1.5">
توضیحات تکمیلی یا اقلام مدنظر (اختیاری)
</label>
<textarea
id="b2b-notes"
rows={3}
placeholder="در صورت داشتن نیاز دارویی خاص یا حجم تخمینی سفارش، در اینجا درج فرمایید..."
value={formData.notes}

View File

@ -189,10 +189,11 @@ export default function ContactFormClient() {
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">
<label htmlFor="contact-name" className="text-xs font-black text-medical-gray-500">
نام و نام خانوادگی *
</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@ -202,8 +203,9 @@ export default function ContactFormClient() {
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">شماره موبایل *</label>
<label htmlFor="contact-phone" className="text-xs font-black text-medical-gray-500">شماره موبایل *</label>
<input
id="contact-phone"
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
@ -216,8 +218,9 @@ export default function ContactFormClient() {
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">موضوع پیام</label>
<label htmlFor="contact-subject" className="text-xs font-black text-medical-gray-500">موضوع پیام</label>
<input
id="contact-subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
@ -227,8 +230,9 @@ export default function ContactFormClient() {
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">متن پیام *</label>
<label htmlFor="contact-message" className="text-xs font-black text-medical-gray-500">متن پیام *</label>
<textarea
id="contact-message"
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}