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
All checks were successful
Deploy Canina / deploy (push) Successful in 1m43s
This commit is contained in:
parent
70c2627d93
commit
7b89e141c5
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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: 'تایید کد پیامکی و ورود به سیستم' })
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
|
||||
56
backend/src/common/env.validation.ts
Normal file
56
backend/src/common/env.validation.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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(
|
||||
|
||||
17
frontend/application/app/b2b/error.tsx
Normal file
17
frontend/application/app/b2b/error.tsx
Normal 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} />;
|
||||
}
|
||||
17
frontend/application/app/blog/error.tsx
Normal file
17
frontend/application/app/blog/error.tsx
Normal 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} />;
|
||||
}
|
||||
31
frontend/application/app/blog/loading.tsx
Normal file
31
frontend/application/app/blog/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/application/app/shop/error.tsx
Normal file
17
frontend/application/app/shop/error.tsx
Normal 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} />;
|
||||
}
|
||||
38
frontend/application/app/shop/loading.tsx
Normal file
38
frontend/application/app/shop/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user