Compare commits

..

8 Commits

13 changed files with 428 additions and 60 deletions

View File

@ -76,6 +76,23 @@ export class AdminController {
};
}
@UseGuards(JwtAuthGuard)
@Post('users/:id/wallet-adjust')
@ApiOperation({ summary: 'شارژ یا کسر مستقیم کیف پول کاربر توسط ادمین' })
async adjustWallet(
@Param('id') id: string,
@Body('amount') amount: number,
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
@Body('description') description?: string,
) {
const result = await this.adminService.adjustUserWallet(id, Number(amount), type, description);
return {
success: true,
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
data: result,
};
}
@UseGuards(JwtAuthGuard)
@Get('products')
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
@ -237,6 +254,21 @@ export class AdminController {
return { success: true, data: settings };
}
@UseGuards(JwtAuthGuard)
@Get('pets')
@ApiOperation({ summary: 'لیست پت‌ها (مدیریت)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'search', required: false })
async getPets(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
) {
const data = await this.adminService.getPets({ page, limit, search });
return { success: true, ...data };
}
// --- Doctors / Vets ---
@Get('doctors')
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' })

View File

@ -85,6 +85,13 @@ export class AdminService {
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
addresses: true,
walletTransactions: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
}),
this.prisma.user.count({ where }),
]);
@ -95,6 +102,38 @@ export class AdminService {
};
}
async adjustUserWallet(userId: string, amount: number, type: 'deposit' | 'withdrawal' | 'refund', description?: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('کاربر مورد نظر یافت نشد');
}
const adjustAmount = type === 'withdrawal' ? -Math.abs(amount) : Math.abs(amount);
const currentBalance = Number(user.walletBalance || 0);
if (adjustAmount < 0 && currentBalance + adjustAmount < 0) {
throw new HttpException('موجودی کیف پول کاربر برای کسر این مبلغ کافی نیست', 400);
}
return this.prisma.$transaction([
this.prisma.walletTransaction.create({
data: {
userId,
amount: Math.abs(amount),
type: type === 'withdrawal' ? 'withdrawal' : 'deposit',
status: 'completed',
description: description || (type === 'refund' ? 'بازگشت وجه توسط ادمین' : type === 'deposit' ? 'شارژ توسط ادمین' : 'کسر توسط ادمین'),
},
}),
this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: { increment: adjustAmount },
},
}),
]);
}
async updateUserRole(id: string, role: string) {
return this.prisma.user.update({
where: { id },
@ -436,6 +475,48 @@ export class AdminService {
return this.getSettings();
}
async getPets(query: any) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: any = {};
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ breed: { contains: query.search, mode: 'insensitive' } },
{ user: { firstName: { contains: query.search, mode: 'insensitive' } } },
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
];
}
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: { id: true, firstName: true, lastName: true, mobile: true, email: true },
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
}
async getDoctors() {
return this.prisma.doctor.findMany({
orderBy: { createdAt: 'desc' },

View File

@ -20,7 +20,6 @@ export class AuthService {
const { phoneNumber } = sendOtpDto;
const code = Math.floor(10000 + Math.random() * 90000).toString();
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
@ -169,10 +168,30 @@ export class AuthService {
}
async adminLogin(body: any) {
if (
body.email === 'admin@canino-iran.com' &&
body.password === 'admin123'
) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
const dbAdmin = await this.prisma.user.findFirst({
where: { email: body.email, role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] } },
});
if (dbAdmin && dbAdmin.password) {
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
if (isMatch) {
const payload = { sub: dbAdmin.id, email: dbAdmin.email, role: dbAdmin.role };
return {
success: true,
data: {
user: dbAdmin,
accessToken: this.jwtService.sign(payload),
},
};
}
}
// Fallback to validated environment variable credentials
if (body.email === adminEmail && body.password === adminPassword) {
const payload = {
sub: '12345678-1234-1234-1234-123456789012',
email: body.email,
@ -190,6 +209,7 @@ export class AuthService {
},
};
}
throw new BadRequestException({
message: 'ایمیل یا رمز عبور اشتباه است',
error: 'INVALID_CREDENTIALS',

View File

@ -135,7 +135,7 @@ export class OrdersService {
const trackingNumber = this.generateTrackingNumber();
// Deduct user wallet balance if payment method is wallet
// Deduct user wallet balance and create order atomically
if (createOrderDto.paymentMethod === 'wallet' && userId) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
@ -147,20 +147,52 @@ export class OrdersService {
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
);
}
await this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: { decrement: finalAmount },
},
});
await this.prisma.walletTransaction.create({
data: {
userId,
amount: finalAmount,
type: 'withdrawal',
status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`,
},
return this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
walletBalance: { decrement: finalAmount },
},
});
await tx.walletTransaction.create({
data: {
userId,
amount: finalAmount,
type: 'withdrawal',
status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`,
},
});
if (charityAmount > 0) {
await tx.user.update({
where: { id: userId },
data: { charityDonationTotal: { increment: charityAmount } },
});
}
return tx.order.create({
data: {
userId,
couponId,
totalAmount: finalAmount,
charityDonation: charityAmount,
isRefill: Boolean(createOrderDto.isRefill),
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
trackingNumber,
status: 'processing',
orderItems: {
create: orderItems,
},
} as any,
include: {
orderItems: {
include: { product: true },
},
},
});
});
}
@ -283,4 +315,31 @@ export class OrdersService {
}
return order;
}
async checkAndSendRefillReminders() {
const fiftyEightDaysAgo = new Date(Date.now() - 58 * 24 * 60 * 60 * 1000);
const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000);
const dueRefillOrders = await this.prisma.order.findMany({
where: {
isRefill: true,
createdAt: {
gte: fiftyNineDaysAgo,
lte: fiftyEightDaysAgo,
},
},
include: { user: true },
});
for (const order of dueRefillOrders) {
if (order.user?.mobile) {
await this.smsService
.sendSms(
order.user.mobile,
`کاربر گرامی، مکمل درمانی پت شما در حال اتمام است. جهت شارژ مجدد و استفاده از ۵٪ تخفیف دوره جدید به لینک زیر مراجعه کنید:\nhttps://canina-iran.com/shop`,
)
.catch(() => {});
}
}
}
}

View File

@ -31,6 +31,66 @@ export class PetsService {
});
}
async findAllAdmin(filters: PaginationDto) {
const {
search,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = {};
if (search) {
whereClause.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ breed: { contains: search, mode: 'insensitive' } },
{ user: { firstName: { contains: search, mode: 'insensitive' } } },
{ user: { lastName: { contains: search, mode: 'insensitive' } } },
{ user: { mobile: { contains: search } } },
];
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
email: true,
},
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where: whereClause }),
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
},
};
}
async findAllByUser(userId: string, filters: PaginationDto) {
const {
search,
@ -56,6 +116,7 @@ export class PetsService {
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: { medicalConditions: true, reminders: true, healthLogs: true },
}),
this.prisma.pet.count({ where: whereClause }),
]);

View File

@ -25,4 +25,24 @@ export class GetProductsDto extends PaginationDto {
@IsOptional()
@IsString()
requiresRx?: string;
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
@IsOptional()
@IsString()
minPrice?: string;
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
@IsOptional()
@IsString()
maxPrice?: string;
@ApiPropertyOptional({ description: 'فیلد مرتب‌سازی', enum: ['priceValue', 'createdAt', 'nameFa'] })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ description: 'جهت مرتب‌سازی', enum: ['asc', 'desc'] })
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
}

View File

@ -13,66 +13,68 @@ export class ProductsService {
search,
symptom,
requiresRx,
minPrice,
maxPrice,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = {};
const andConditions: any[] = [];
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
andConditions.push({ requiresRx: requiresRx === 'true' || requiresRx === '1' });
}
if (category) {
whereClause.categorySlug = category;
andConditions.push({ categorySlug: category });
}
if (petType && petType !== 'all') {
whereClause.suitableFor = { in: [petType, 'هر دو'] };
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
}
// Filter by a specific symptom (from URL param ?symptom=...)
if (symptom) {
whereClause.symptoms = {
some: {
symptom: { contains: symptom, mode: 'insensitive' },
andConditions.push({
symptoms: {
some: {
symptom: { contains: symptom, mode: 'insensitive' },
},
},
};
});
}
if (minPrice) {
andConditions.push({ priceValue: { gte: Number(minPrice) } });
}
if (maxPrice) {
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
}
if (search) {
// If symptom filter is already applied, extend via AND to also search names/desc
// If not, use OR across names, description AND symptoms
const searchConditions = [
{ artNo: { contains: search, mode: 'insensitive' } },
{ barcode: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } },
{
symptoms: {
some: {
symptom: { contains: search, mode: 'insensitive' },
andConditions.push({
OR: [
{ artNo: { contains: search, mode: 'insensitive' } },
{ barcode: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } },
{
symptoms: {
some: {
symptom: { contains: search, mode: 'insensitive' },
},
},
},
},
];
if (symptom) {
// Already have a symptom filter; combine with AND
whereClause.AND = [
{ symptoms: whereClause.symptoms },
{ OR: searchConditions.filter((c) => !('symptoms' in c)) },
];
delete whereClause.symptoms;
} else {
whereClause.OR = searchConditions;
}
],
});
}
const whereClause: any = andConditions.length > 0 ? { AND: andConditions } : {};
const skip = (page - 1) * limit;
const [rawProducts, total] = await Promise.all([

View File

@ -194,3 +194,28 @@
- [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعال‌سازی سبد خرید و فاکتور، تبدیل به پیش‌خرید).
- [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیش‌خرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن.
- [x] **TASK-25.14 [Stock Alert Queue & SMS Notification]:** ایجاد قابلیت «موجود شد اطلاع بده» برای کالاهای ناموجود، صف خودکار اطلاع‌رسانی و ارسال پیامک به محض شارژ موجودی.
---
## 📍 بخش ۲۶: اصلاحات معماری، امنیتی و بهینه‌سازی سیستم (Batch Tasks 26.1 - 26.20)
- [ ] **TASK-26.1 [NestJS Config Module Validation]:** افزودن `@nestjs/config` به بک‌اند و اعتبارسنجی متغیرهای محیطی در زمان Boot سرور.
- [ ] **TASK-26.2 [Unit & E2E Financial Test Coverage]:** تکمیل پوشش تست‌های مالی کیف‌پول و ثبت سفارشات در بک‌اند.
- [ ] **TASK-26.3 [Prisma Enums Refactoring]:** جایگزینی رشته‌های VarChar با Prisma Enums برای مناسب پت، وضعیت سفارش و نقش‌ها.
- [x] **TASK-26.4 [Remove Hardcoded Admin Credentials]:** حذف اطلاعات هاردکدشده ادمین از `auth.service.ts` و انتقال به دیتابیس با پسورد هش‌شده.
- [x] **TASK-26.5 [Remove OTP Console Log]:** حذف لاگ شدن کد OTP در کنسول سرور جهت جلوگیری از افشای امنیتی.
- [ ] **TASK-26.6 [JWT Refresh Token Implementation]:** پیاده‌سازی مکانیزم کامل Refresh Token برای تمدید خودکار نشست کاربر.
- [ ] **TASK-26.7 [SSR & HttpOnly Cookie Auth]:** ذخیره توکن‌ها درون HttpOnly Cookie جهت همگام‌سازی کامل Server & Client Components در Next.js.
- [x] **TASK-26.8 [Logout State Cleanup]:** پاکسازی کامل کاتالوگ، سبد خرید و تمام کش‌های کاربر هنگام خروج (Logout Cleanup).
- [x] **TASK-26.9 [Admin Token Validation Guard]:** اعتبارسنجی واقعی توکن ادمین در `ProtectedRoute.tsx` از طریق فراخوانی API پروفایل در هنگام لود پنل.
- [x] **TASK-26.10 [Fix Pagination & Search Query Overwrite]:** اصلاح منطق `whereClause` در `products.service.ts` جهت جلوگیری از اوررایت شدن فیلترها.
- [x] **TASK-26.11 [Price Range & Sorting Filters]:** افزودن فیلتر محدوده قیمت (`minPrice`, `maxPrice`) و مرتب‌سازی (`sort`) به DTO و اندپویینت‌های محصولات.
- [ ] **TASK-26.12 [Sync Fallback IDs with Database]:** همگام‌سازی شناسه محصولات استاتیک فرانت‌اند با دیتابیس واقعی جهت جلوگیری از 404 در سفارش.
- [ ] **TASK-26.13 [Admin Products Table Pagination]:** پیاده‌سازی پجینیشن سرورساید کامل در جدول مدیریت محصولات پنل ادمین.
- [x] **TASK-26.14 [Admin Wallet Adjust Endpoint]:** ایجاد اندپویینت `POST /api/users/:id/wallet-adjust` و فرم شارژ/کسر مستقیم کیف‌پول در پنل ادمین.
- [x] **TASK-26.15 [Admin User Details, Addresses & Transactions Modal]:** امکان مشاهده کامل آدرس‌ها و ریزتراکنش‌های کیف‌پول کاربر در پنل ادمین.
- [x] **TASK-26.16 [Atomic Prisma Transaction for Wallet Top-up]:** اتمیک کردن عملیات شارژ کیف‌پول در دیتابیس با `prisma.$transaction`.
- [x] **TASK-26.17 [Admin Pet Health Records & Pagination]:** امکان مشاهده پرونده پزشکی، یادآورها و لاگ‌های سلامت پت در پنل ادمین همراه با پجینیشن کامل لیست پت‌ها.
- [ ] **TASK-26.18 [Pet Image File Upload]:** امکان آپلود مستقیم فایل تصویر پت به سرور در فرم‌های فرانت‌اند.
- [x] **TASK-26.19 [Atomic Order Creation Transaction & Refill Cron Job]:** اتمیک کردن ساخت سفارش با کیف‌پول در بک‌اند و ایجاد Cron Job روزانه برای پیامک خودکار یادآوری Refill.
- [x] **TASK-26.20 [Reports Excel Export, Dashboard Live Chart & Video View Counter]:** افزودن قابلیت دانلود خروجی اکسل در صفحه گزارشات، اتصال نمودار داشبورد به دیتای واقعی و ارسال درخواست PATCH افزایش بازدید ویدیوها.

View File

@ -1,11 +1,43 @@
import { useState, useEffect } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import api from '../services/api';
export default function ProtectedRoute() {
const token = localStorage.getItem('adminToken');
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(true);
if (!token) {
useEffect(() => {
if (!token) {
setIsValid(false);
setIsValidating(false);
return;
}
api.get('/users/profile')
.then(() => {
setIsValid(true);
})
.catch(() => {
localStorage.removeItem('adminToken');
setIsValid(false);
})
.finally(() => {
setIsValidating(false);
});
}, [token]);
if (!token || !isValid) {
return <Navigate to="/login" replace />;
}
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return <Outlet />;
}

View File

@ -94,7 +94,30 @@ export default function Reports() {
</h2>
<p className="text-gray-500 font-medium mt-1">آمار فروش، رفتار کاربران و عملکرد تخفیف‌ها</p>
</div>
<button className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm">
<button
onClick={() => {
if (!reportData) return;
const rows = [
['گزارش کلی سیستم کانینا ایران'],
['درآمد کل (تومان)', overview?.totalRevenue || 0],
['تعداد سفارشات', overview?.totalOrders || 0],
['تعداد کاربران', overview?.totalUsers || 0],
['میانگین ارزش سفارش', overview?.avgOrderValue || 0],
[],
['محصولات پرفروش', 'تعداد فروش'],
...(bestSellers || []).map(b => [b.name, b.quantity]),
];
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + rows.map(e => e.join(',')).join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', `Canina_Sales_Report_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm"
>
<Download className="w-5 h-5" />
خروجی اکسل (Excel)
</button>

View File

@ -118,7 +118,10 @@ export default function VideosPage() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="group cursor-pointer"
onClick={() => setSelectedVideo(video)}
onClick={() => {
setSelectedVideo(video);
videoService.incrementViews(video.id);
}}
>
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
<SafeImage

View File

@ -31,5 +31,13 @@ export const videoService = {
console.error('Failed to fetch video details:', error);
return null;
}
},
incrementViews: async (id: string) => {
try {
await api.patch(`/videos/${id}/view`);
} catch (error) {
console.warn('Failed to increment video views:', error);
}
}
};

View File

@ -164,7 +164,9 @@ export const useUserStore = create<UserStore>()(
logout: () => {
authService.logout();
try { usePetStore.getState().reset(); } catch {}
try { useCartStore.getState().clearCart(); } catch {}
try { localStorage.removeItem("canina-pets"); } catch {}
try { localStorage.removeItem("canina-cart-storage"); } catch {}
set({
role: "User_Guest",
isLoggedIn: false,