Compare commits
No commits in common. "187aa7c962322a605e4918144c654caef9ebf57e" and "ce30ade80880d98da8809563fd01d59dd328178e" have entirely different histories.
187aa7c962
...
ce30ade808
@ -76,23 +76,6 @@ 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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('products')
|
@Get('products')
|
||||||
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
||||||
@ -254,21 +237,6 @@ export class AdminController {
|
|||||||
return { success: true, data: settings };
|
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 ---
|
// --- Doctors / Vets ---
|
||||||
@Get('doctors')
|
@Get('doctors')
|
||||||
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' })
|
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' })
|
||||||
|
|||||||
@ -85,13 +85,6 @@ export class AdminService {
|
|||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
|
||||||
addresses: true,
|
|
||||||
walletTransactions: {
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
this.prisma.user.count({ where }),
|
this.prisma.user.count({ where }),
|
||||||
]);
|
]);
|
||||||
@ -102,38 +95,6 @@ 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) {
|
async updateUserRole(id: string, role: string) {
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@ -475,48 +436,6 @@ export class AdminService {
|
|||||||
return this.getSettings();
|
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() {
|
async getDoctors() {
|
||||||
return this.prisma.doctor.findMany({
|
return this.prisma.doctor.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export class AuthService {
|
|||||||
const { phoneNumber } = sendOtpDto;
|
const { phoneNumber } = sendOtpDto;
|
||||||
|
|
||||||
const code = Math.floor(10000 + Math.random() * 90000).toString();
|
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);
|
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
||||||
|
|
||||||
@ -168,30 +169,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async adminLogin(body: any) {
|
async adminLogin(body: any) {
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
|
if (
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
body.email === 'admin@canino-iran.com' &&
|
||||||
|
body.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 = {
|
const payload = {
|
||||||
sub: '12345678-1234-1234-1234-123456789012',
|
sub: '12345678-1234-1234-1234-123456789012',
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@ -209,7 +190,6 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'ایمیل یا رمز عبور اشتباه است',
|
message: 'ایمیل یا رمز عبور اشتباه است',
|
||||||
error: 'INVALID_CREDENTIALS',
|
error: 'INVALID_CREDENTIALS',
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export class OrdersService {
|
|||||||
|
|
||||||
const trackingNumber = this.generateTrackingNumber();
|
const trackingNumber = this.generateTrackingNumber();
|
||||||
|
|
||||||
// Deduct user wallet balance and create order atomically
|
// Deduct user wallet balance if payment method is wallet
|
||||||
if (createOrderDto.paymentMethod === 'wallet' && userId) {
|
if (createOrderDto.paymentMethod === 'wallet' && userId) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -147,52 +147,20 @@ export class OrdersService {
|
|||||||
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.prisma.user.update({
|
||||||
return this.prisma.$transaction(async (tx) => {
|
where: { id: userId },
|
||||||
await tx.user.update({
|
data: {
|
||||||
where: { id: userId },
|
walletBalance: { decrement: finalAmount },
|
||||||
data: {
|
},
|
||||||
walletBalance: { decrement: finalAmount },
|
});
|
||||||
},
|
await this.prisma.walletTransaction.create({
|
||||||
});
|
data: {
|
||||||
|
userId,
|
||||||
await tx.walletTransaction.create({
|
amount: finalAmount,
|
||||||
data: {
|
type: 'withdrawal',
|
||||||
userId,
|
status: 'completed',
|
||||||
amount: finalAmount,
|
description: `پرداخت سفارش ${trackingNumber}`,
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,31 +283,4 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
return order;
|
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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,66 +31,6 @@ 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) {
|
async findAllByUser(userId: string, filters: PaginationDto) {
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@ -116,7 +56,6 @@ export class PetsService {
|
|||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { [sortBy]: sortOrder },
|
orderBy: { [sortBy]: sortOrder },
|
||||||
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
|
||||||
}),
|
}),
|
||||||
this.prisma.pet.count({ where: whereClause }),
|
this.prisma.pet.count({ where: whereClause }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -25,24 +25,4 @@ export class GetProductsDto extends PaginationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
requiresRx?: string;
|
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';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,67 +13,65 @@ export class ProductsService {
|
|||||||
search,
|
search,
|
||||||
symptom,
|
symptom,
|
||||||
requiresRx,
|
requiresRx,
|
||||||
minPrice,
|
|
||||||
maxPrice,
|
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
sortBy = 'createdAt',
|
sortBy = 'createdAt',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const andConditions: any[] = [];
|
const whereClause: any = {};
|
||||||
|
|
||||||
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
||||||
andConditions.push({ requiresRx: requiresRx === 'true' || requiresRx === '1' });
|
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
andConditions.push({ categorySlug: category });
|
whereClause.categorySlug = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (petType && petType !== 'all') {
|
if (petType && petType !== 'all') {
|
||||||
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
|
whereClause.suitableFor = { in: [petType, 'هر دو'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by a specific symptom (from URL param ?symptom=...)
|
||||||
if (symptom) {
|
if (symptom) {
|
||||||
andConditions.push({
|
whereClause.symptoms = {
|
||||||
symptoms: {
|
some: {
|
||||||
some: {
|
symptom: { contains: symptom, mode: 'insensitive' },
|
||||||
symptom: { contains: symptom, mode: 'insensitive' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (minPrice) {
|
|
||||||
andConditions.push({ priceValue: { gte: Number(minPrice) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxPrice) {
|
|
||||||
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
andConditions.push({
|
// If symptom filter is already applied, extend via AND to also search names/desc
|
||||||
OR: [
|
// If not, use OR across names, description AND symptoms
|
||||||
{ artNo: { contains: search, mode: 'insensitive' } },
|
const searchConditions = [
|
||||||
{ barcode: { contains: search, mode: 'insensitive' } },
|
{ artNo: { contains: search, mode: 'insensitive' } },
|
||||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
{ barcode: { contains: search, mode: 'insensitive' } },
|
||||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||||
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
{
|
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
||||||
symptoms: {
|
{
|
||||||
some: {
|
symptoms: {
|
||||||
symptom: { contains: search, mode: 'insensitive' },
|
some: {
|
||||||
},
|
symptom: { contains: search, mode: 'insensitive' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
});
|
];
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause: any = andConditions.length > 0 ? { AND: andConditions } : {};
|
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 skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
|||||||
@ -194,28 +194,3 @@
|
|||||||
- [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعالسازی سبد خرید و فاکتور، تبدیل به پیشخرید).
|
- [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعالسازی سبد خرید و فاکتور، تبدیل به پیشخرید).
|
||||||
- [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیشخرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن.
|
- [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیشخرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن.
|
||||||
- [x] **TASK-25.14 [Stock Alert Queue & SMS Notification]:** ایجاد قابلیت «موجود شد اطلاع بده» برای کالاهای ناموجود، صف خودکار اطلاعرسانی و ارسال پیامک به محض شارژ موجودی.
|
- [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 افزایش بازدید ویدیوها.
|
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
import api from '../services/api';
|
|
||||||
|
|
||||||
export default function ProtectedRoute() {
|
export default function ProtectedRoute() {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
const [isValidating, setIsValidating] = useState(true);
|
|
||||||
const [isValid, setIsValid] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!token) {
|
||||||
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 />;
|
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 />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,30 +94,7 @@ export default function Reports() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 font-medium mt-1">آمار فروش، رفتار کاربران و عملکرد تخفیفها</p>
|
<p className="text-gray-500 font-medium mt-1">آمار فروش، رفتار کاربران و عملکرد تخفیفها</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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">
|
||||||
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" />
|
<Download className="w-5 h-5" />
|
||||||
خروجی اکسل (Excel)
|
خروجی اکسل (Excel)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -118,10 +118,7 @@ export default function VideosPage() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: idx * 0.1 }}
|
transition={{ delay: idx * 0.1 }}
|
||||||
className="group cursor-pointer"
|
className="group cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => setSelectedVideo(video)}
|
||||||
setSelectedVideo(video);
|
|
||||||
videoService.incrementViews(video.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
|
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
|
||||||
<SafeImage
|
<SafeImage
|
||||||
|
|||||||
@ -31,13 +31,5 @@ export const videoService = {
|
|||||||
console.error('Failed to fetch video details:', error);
|
console.error('Failed to fetch video details:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
incrementViews: async (id: string) => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/videos/${id}/view`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to increment video views:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -164,9 +164,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
authService.logout();
|
authService.logout();
|
||||||
try { usePetStore.getState().reset(); } catch {}
|
try { usePetStore.getState().reset(); } catch {}
|
||||||
try { useCartStore.getState().clearCart(); } catch {}
|
|
||||||
try { localStorage.removeItem("canina-pets"); } catch {}
|
try { localStorage.removeItem("canina-pets"); } catch {}
|
||||||
try { localStorage.removeItem("canina-cart-storage"); } catch {}
|
|
||||||
set({
|
set({
|
||||||
role: "User_Guest",
|
role: "User_Guest",
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user