canina/backend/src/admin/admin.service.ts
parsa aghaei e1f471c978
All checks were successful
Deploy Canina / deploy (push) Successful in 1m43s
fix(auth): normalize mobile digits and whitespace in login, register, and admin user creation
2026-08-16 15:31:28 +03:30

749 lines
22 KiB
TypeScript

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<string, string>,
);
}
async updateSettings(data: Record<string, string>) {
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;
}
}