import { Injectable, HttpException, NotFoundException, BadRequestException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; import * as bcrypt from 'bcryptjs'; import { CreateUserDto, UpdateUserDto } from './dto/user.dto'; import { normalizeMobile } from '../common/utils/phone.utils'; export class PaginationQuery { page?: number | string; limit?: number | string; search?: string; role?: string; categoryId?: string; status?: string; } export class ProductInput { artNo?: string; nameFa?: string; nameEn?: string; scientificTagline?: string; description?: string; shortDescription?: string; categoryId?: string; categorySlug?: string; priceValue?: number; priceDisplay?: string; unit?: string; packageSize?: number; dosageLogic?: string; suitableFor?: string; imageUrl?: string; images?: string | string[]; podcastUrl?: string | null; videoUrl?: string | null; pdfUrl?: string | null; metaTitle?: string; metaDescription?: string; keywords?: string; canonicalUrl?: string; slug?: string; symptoms?: string[]; } export class CouponTargetInput { targetType!: string; targetId!: string; modifierType?: string; modifierValue?: number; } export class CouponInput { code!: string; type?: string; value!: number; minCartValue?: number; maxCartValue?: number; maxUses?: number; expiresAt?: string | Date; isActive?: boolean; targets?: CouponTargetInput[]; } @Injectable() export class AdminService { constructor( private prisma: PrismaService, private redisService: RedisService, ) {} async getDashboardStats() { const orders = await this.prisma.order.findMany({ where: { status: { not: 'failed' } }, select: { totalAmount: true }, }); const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0); const newOrders = await this.prisma.order.count({ where: { status: 'processing' }, }); const users = await this.prisma.user.count(); const todayKey = `visits:${new Date().toISOString().split('T')[0]}`; let todayVisits = 0; try { const count = await this.redisService.get(todayKey); todayVisits = count ? parseInt(count, 10) : 0; } catch { todayVisits = 0; } const [dogCount, catCount, bothCount] = await Promise.all([ this.prisma.product.count({ where: { suitableFor: 'سگ' } }), this.prisma.product.count({ where: { suitableFor: 'گربه' } }), this.prisma.product.count({ where: { suitableFor: { contains: 'هر دو' } }, }), ]); return { revenue, newOrders, users, todayVisits, categoriesDistribution: [ { name: 'مکمل سگ', value: dogCount || 8 }, { name: 'مکمل گربه', value: catCount || 6 }, { name: 'هر دو (سگ و گربه)', value: bothCount || 12 }, ], }; } async getUsers(query: PaginationQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; const where: Prisma.UserWhereInput = {}; if (query.search) { where.OR = [ { firstName: { contains: query.search, mode: 'insensitive' } }, { lastName: { contains: query.search, mode: 'insensitive' } }, { email: { contains: query.search, mode: 'insensitive' } }, { mobile: { contains: query.search } }, ]; } if (query.role) { if (query.role === 'B2B') { where.role = { contains: 'B2B' }; } else { where.role = query.role; } } const [data, total] = await Promise.all([ this.prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' }, include: { addresses: true, walletTransactions: { orderBy: { createdAt: 'desc' }, take: 20, }, }, }), this.prisma.user.count({ where }), ]); return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) }, }; } async getUserDetails(id: string) { const user = await this.prisma.user.findUnique({ where: { id }, include: { addresses: true, pets: true, orders: { orderBy: { createdAt: 'desc' }, take: 10 }, walletTransactions: { orderBy: { createdAt: 'desc' }, take: 20 }, }, }); if (!user) throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد.`); return user; } async createUser(dto: CreateUserDto) { const mobile = normalizeMobile(dto.mobile); const existingMobile = await this.prisma.user.findUnique({ where: { mobile }, }); if (existingMobile) { throw new BadRequestException('کاربری با این شماره موبایل قبلاً در سیستم ثبت شده است'); } if (dto.email) { const existingEmail = await this.prisma.user.findUnique({ where: { email: dto.email.trim().toLowerCase() }, }); if (existingEmail) { throw new BadRequestException('کاربری با این آدرس ایمیل قبلاً در سیستم ثبت شده است'); } } const passwordToHash = dto.password?.trim(); const hashedPassword = passwordToHash ? await bcrypt.hash(passwordToHash, 10) : ''; return this.prisma.user.create({ data: { firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), mobile, email: dto.email ? dto.email.trim().toLowerCase() : null, password: hashedPassword, role: dto.role || 'User_PetOwner', walletBalance: dto.walletBalance !== undefined ? new Prisma.Decimal(dto.walletBalance) : new Prisma.Decimal(0), }, }); } async updateUser(id: string, dto: UpdateUserDto) { if (id === '12345678-1234-1234-1234-123456789012') { throw new BadRequestException('امکان ویرایش حساب ادمین پیش‌فرض وجود ندارد'); } const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) { throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد`); } const normalizedMobile = dto.mobile ? normalizeMobile(dto.mobile) : undefined; if (normalizedMobile && normalizedMobile !== user.mobile) { const existingMobile = await this.prisma.user.findUnique({ where: { mobile: normalizedMobile }, }); if (existingMobile && existingMobile.id !== id) { throw new BadRequestException('این شماره موبایل متعلق به کاربر دیگری است'); } } const normalizedEmail = dto.email ? dto.email.trim().toLowerCase() : undefined; if (normalizedEmail && normalizedEmail !== user.email) { const existingEmail = await this.prisma.user.findUnique({ where: { email: normalizedEmail }, }); if (existingEmail && existingEmail.id !== id) { throw new BadRequestException('این آدرس ایمیل متعلق به کاربر دیگری است'); } } const updateData: Prisma.UserUpdateInput = {}; if (dto.firstName !== undefined) updateData.firstName = dto.firstName.trim(); if (dto.lastName !== undefined) updateData.lastName = dto.lastName.trim(); if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile; if (normalizedEmail !== undefined) updateData.email = normalizedEmail || null; if (dto.role !== undefined) updateData.role = dto.role; if (dto.walletBalance !== undefined) { updateData.walletBalance = new Prisma.Decimal(dto.walletBalance); } if (dto.password && dto.password.trim().length >= 6) { updateData.password = await bcrypt.hash(dto.password.trim(), 10); } return this.prisma.user.update({ where: { id }, data: updateData, }); } async updateUserRole(id: string, role: string) { return this.prisma.user.update({ where: { id }, data: { role }, }); } async deleteUser(id: string) { if (id === '12345678-1234-1234-1234-123456789012') { throw new BadRequestException('امکان حذف حساب ادمین پیش‌فرض وجود ندارد'); } const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) { throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد`); } return this.prisma.$transaction(async (tx) => { await tx.userAddress.deleteMany({ where: { userId: id } }); await tx.walletTransaction.deleteMany({ where: { userId: id } }); await tx.pet.deleteMany({ where: { userId: id } }); await tx.prescription.deleteMany({ where: { userId: id } }); await tx.paymentTransaction.deleteMany({ where: { userId: id } }); await tx.partnerAccount.deleteMany({ where: { userId: id } }); await tx.blog.deleteMany({ where: { authorId: id } }); await tx.order.deleteMany({ where: { userId: id } }); return tx.user.delete({ where: { id } }); }); } async getProducts(query: PaginationQuery = {}) { try { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; const where: Prisma.ProductWhereInput = {}; if (query.search) { where.OR = [ { nameFa: { contains: query.search, mode: 'insensitive' } }, { artNo: { contains: query.search } }, ]; } if (query.categoryId) { where.categoryId = query.categoryId; } const [data, total] = await Promise.all([ this.prisma.product.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' }, include: { category: true, symptoms: true }, }), this.prisma.product.count({ where }), ]); return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) }, }; } catch (error) { const err = error as { message?: string }; console.error('[AdminService] getProducts error:', err); throw new HttpException(err.message || 'Error fetching products', 500); } } async createProduct(data: ProductInput) { const product = await this.prisma.product.create({ data: { artNo: data.artNo || `ART-${Date.now()}`, nameFa: data.nameFa || '', nameEn: data.nameEn || '', scientificTagline: data.scientificTagline || '', description: data.description || '', shortDescription: data.shortDescription || '', categoryId: data.categoryId || '', categorySlug: data.categorySlug || 'general', priceValue: data.priceValue || 0, priceDisplay: data.priceDisplay || '', unit: data.unit || 'عدد', packageSize: data.packageSize || 100, dosageLogic: data.dosageLogic || '', suitableFor: data.suitableFor || 'سگ', imageUrl: data.imageUrl || '', images: Array.isArray(data.images) ? data.images : data.images ? [data.images] : [], podcastUrl: data.podcastUrl || null, videoUrl: data.videoUrl || null, pdfUrl: data.pdfUrl || null, metaTitle: data.metaTitle || '', metaDescription: data.metaDescription || '', keywords: data.keywords || '', canonicalUrl: data.canonicalUrl || '', slug: data.slug || data.artNo || `slug-${Date.now()}`, }, }); if (data.symptoms && Array.isArray(data.symptoms)) { await this.prisma.productSymptom.createMany({ data: data.symptoms .map((s: string) => ({ productId: product.id, symptom: s.trim(), })) .filter((s: { symptom: string }) => s.symptom.length > 0), }); } return product; } async updateProduct(id: string, data: ProductInput) { const existing = await this.prisma.product.findUnique({ where: { id } }); if (!existing) { throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`); } const product = await this.prisma.product.update({ where: { id }, data: { artNo: data.artNo, nameFa: data.nameFa, nameEn: data.nameEn, scientificTagline: data.scientificTagline, description: data.description, shortDescription: data.shortDescription, categoryId: data.categoryId, categorySlug: data.categorySlug, priceValue: data.priceValue, priceDisplay: data.priceDisplay, unit: data.unit, packageSize: data.packageSize, dosageLogic: data.dosageLogic, suitableFor: data.suitableFor, imageUrl: data.imageUrl, images: Array.isArray(data.images) ? data.images : data.images ? [data.images] : undefined, podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined, videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined, pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined, metaTitle: data.metaTitle, metaDescription: data.metaDescription, keywords: data.keywords, canonicalUrl: data.canonicalUrl, slug: data.slug || data.artNo, }, }); if (data.symptoms !== undefined && Array.isArray(data.symptoms)) { await this.prisma.productSymptom.deleteMany({ where: { productId: id } }); if (data.symptoms.length > 0) { await this.prisma.productSymptom.createMany({ data: data.symptoms .map((s: string) => ({ productId: id, symptom: s.trim(), })) .filter((s: { symptom: string }) => s.symptom.length > 0), }); } } return product; } async deleteProduct(id: string) { return this.prisma.product.delete({ where: { id }, }); } async getOrders(query: PaginationQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; const where: Prisma.OrderWhereInput = {}; if (query.search) { where.OR = [ { trackingNumber: { contains: query.search, mode: 'insensitive' } }, { user: { firstName: { contains: query.search, mode: 'insensitive' } }, }, { user: { lastName: { contains: query.search, mode: 'insensitive' } } }, { user: { mobile: { contains: query.search } } }, ]; } if (query.status) { where.status = query.status; } const [data, total] = await Promise.all([ this.prisma.order.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' }, include: { user: true, orderItems: { include: { product: true, }, }, coupon: true, }, }), this.prisma.order.count({ where }), ]); return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) }, }; } async updateOrderStatus(id: string, status: string, trackingNumber?: string) { const dataToUpdate: Prisma.OrderUpdateInput = { status }; if (trackingNumber !== undefined) { dataToUpdate.trackingNumber = trackingNumber; } return this.prisma.order.update({ where: { id }, data: dataToUpdate, include: { user: true, orderItems: { include: { product: true, }, }, }, }); } async getCoupons(query: PaginationQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; const where: Prisma.CouponWhereInput = query.search ? { code: { contains: query.search, mode: 'insensitive' } } : {}; const [data, total] = await Promise.all([ this.prisma.coupon.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' }, include: { targets: true }, }), this.prisma.coupon.count({ where }), ]); return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit), }, }; } async createCoupon(data: CouponInput) { return this.prisma.coupon.create({ data: { code: data.code, type: data.type || 'percent', value: data.value, minCartValue: data.minCartValue || null, maxCartValue: data.maxCartValue || null, maxUses: data.maxUses || null, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, isActive: data.isActive !== undefined ? data.isActive : true, targets: data.targets && data.targets.length > 0 ? { create: data.targets.map((t) => ({ targetType: t.targetType, targetId: t.targetId, modifierType: t.modifierType || 'override', modifierValue: t.modifierValue || null, })), } : undefined, }, include: { targets: true }, }); } async updateCoupon(id: string, data: CouponInput) { await this.prisma.couponTarget.deleteMany({ where: { couponId: id } }); return this.prisma.coupon.update({ where: { id }, data: { code: data.code, type: data.type, value: data.value, minCartValue: data.minCartValue, maxCartValue: data.maxCartValue, maxUses: data.maxUses, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, isActive: data.isActive, targets: data.targets && data.targets.length > 0 ? { create: data.targets.map((t) => ({ targetType: t.targetType, targetId: t.targetId, modifierType: t.modifierType || 'override', modifierValue: t.modifierValue || null, })), } : undefined, }, include: { targets: true }, }); } async toggleCoupon(id: string, isActive: boolean) { return this.prisma.coupon.update({ where: { id }, data: { isActive }, }); } async deleteCoupon(id: string) { return this.prisma.coupon.delete({ where: { id }, }); } async getSettings() { const settings = await this.prisma.uiText.findMany(); return settings.reduce( (acc, curr) => ({ ...acc, [curr.key]: curr.value }), {} as Record, ); } async updateSettings(data: Record) { const operations = Object.entries(data).map(([key, value]) => { return this.prisma.uiText.upsert({ where: { key }, update: { value: String(value) }, create: { key, value: String(value) }, }); }); await this.prisma.$transaction(operations); return this.getSettings(); } async getPets(query: PaginationQuery = {}) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; const where: Prisma.PetWhereInput = {}; 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' }, }); } async createDoctor(data: { name: string; title: string; avatarUrl?: string; bio?: string; clinic?: string; }) { return this.prisma.doctor.create({ data }); } async updateDoctor( id: string, data: { name?: string; title?: string; avatarUrl?: string; bio?: string; clinic?: string; }, ) { return this.prisma.doctor.update({ where: { id }, data }); } async deleteDoctor(id: string) { return this.prisma.doctor.delete({ where: { id } }); } 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(`کاربری با شناسه ${userId} یافت نشد.`); } const transaction = await this.prisma.walletTransaction.create({ data: { userId, amount, type, status: 'completed', description: description || 'تغییر دستی توسط مدیر سیستم', }, }); const isIncrement = type === 'deposit' || type === 'refund'; await this.prisma.user.update({ where: { id: userId }, data: { walletBalance: isIncrement ? { increment: amount } : { decrement: amount }, }, }); return transaction; } }