Files
dkala-api/src/modules/users/providers/user.service.ts
T
2026-02-08 10:49:16 +03:30

389 lines
13 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
import { User } from '../entities/user.entity';
import { UserAddress } from '../entities/user-address.entity';
import { Shop } from ../../../shops/entities/shop.entity';
import { EntityManager } from '@mikro-orm/postgresql';
import { UpdateUserDto } from '../dto/update-user.dto';
import { FindUsersDto } from '../dto/find-user.dto';
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
import { CreateWalletTransactionDto } from '../dto/create-wallet-transaction.dto';
import { CreateUserAddressDto } from '../dto/create-user-address.dto';
import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { UserRepository } from '../repositories/user.repository';
import { normalizePhone } from '../../../utils/phone.util';
import { RestRepository } from ../../../shops/repositories/rest.repository';
import { WalletTransactionRepository } from '../repositories/wallet-transaction.repository';
import { WalletService } from './wallet.service';
import { WalletTransactionReason, WalletTransactionType } from '../interface/wallet';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly restaurantRepository: RestRepository,
private readonly walletTransactionRepository: WalletTransactionRepository,
private readonly walletService: WalletService,
private readonly em: EntityManager,
) { }
async findOrCreateByPhone(phone: string): Promise<User> {
const normalizedPhone = normalizePhone(phone);
let user = await this.userRepository.findOne({ phone: normalizedPhone });
if (!user) {
// firstName is required on the entity; use phone as a sensible default
const _raw = {
phone: normalizedPhone,
firstName: '[نام]',
// keep dates undefined (no value) — cast through unknown to satisfy TS typing
birthDate: undefined,
marriageDate: undefined,
wallet: 0,
points: 0,
};
const createData = _raw as unknown as RequiredEntityData<User>;
user = this.userRepository.create(createData);
await this.em.persistAndFlush(user);
}
return user;
}
// async updateUser(userId: string, dto: UpdateUserDto): Promise<User> {
// const user = await this.em.findOneOrFail(User, userId, {}).catch(() => {
// throw new NotFoundException(`User with ID ${userId} not found.`);
// });
// this.em.assign(user, { ...dto });
// await this.em.flush();
// return user;
// }
async findByPhone(phone: string): Promise<User | null> {
const normalizedPhone = normalizePhone(phone);
return this.userRepository.findOne({ phone: normalizedPhone });
}
async findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ id });
}
// todo : transaction code here
async create(phone: string, restId: string): Promise<User> {
const normalizedPhone = normalizePhone(phone);
const _raw = {
phone: normalizedPhone,
firstName: normalizedPhone,
birthDate: undefined,
marriageDate: undefined,
};
// get registerscore from shop
const shop = await this.restaurantRepository.findOne({ id: restId });
if (!shop) {
throw new NotFoundException(`Shop not found.`);
}
const points = Number(shop.score?.registerScore || 0);
const createData = _raw as unknown as RequiredEntityData<User>;
const user = this.userRepository.create(createData);
const newWalletTransaction = this.walletTransactionRepository.create({
user: user,
shop: shop,
balance: 0,
amount: points,
type: WalletTransactionType.CREDIT,
reason: WalletTransactionReason.DEPOSIT
});
await this.em.persistAndFlush([user, newWalletTransaction]);
return user;
}
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepository.findOne({ id: userId });
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
// Normalize date strings into Date objects if provided
const assignData: Partial<User> = { ...dto } as Partial<User>;
if (dto.birthDate) {
assignData.birthDate = new Date(dto.birthDate);
}
if (dto.marriageDate) {
assignData.marriageDate = new Date(dto.marriageDate);
}
// get restuarant
this.em.assign(user, assignData);
await this.em.flush();
return user;
}
async findAll(restId: string, dto: FindUsersDto): Promise<PaginatedResult<User>> {
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
// 1. Calculate pagination
const offset = (page - 1) * limit;
// 2. Build the 'where' filter query
const where: FilterQuery<User> = {
orders: {
shop: {
id: restId,
},
},
};
// 4. Add 'search' logic (case-insensitive)
if (search) {
const searchPattern = `%${search}%`;
const normalizedSearch = normalizePhone(search);
const normalizedSearchPattern = `%${normalizedSearch}%`;
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default)
// Search with both original and normalized phone patterns to handle various input formats
where.$or = [
{ firstName: { $ilike: searchPattern } },
{ lastName: { $ilike: searchPattern } },
{ phone: { $ilike: searchPattern } },
...(normalizedSearch !== search ? [{ phone: { $ilike: normalizedSearchPattern } }] : []),
];
}
// 5. Execute the query using findAndCount
const [users, total] = await this.userRepository.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
});
// 6. Calculate total pages
const totalPages = Math.ceil(total / limit);
// 7. Return the paginated result
return {
data: users,
meta: {
total,
page,
limit,
totalPages,
},
};
}
async getUserAddresses(userId: string): Promise<UserAddress[]> {
const user = await this.userRepository.findOne({ id: userId }, { populate: ['addresses'] });
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
// Load addresses if not already loaded
await user.addresses.loadItems();
return user.addresses.getItems();
}
async createUserAddress(userId: string, dto: CreateUserAddressDto): Promise<UserAddress> {
const user = await this.userRepository.findOne({ id: userId });
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
// If setting as default, unset other default addresses
if (dto.isDefault) {
const existingAddresses = await this.em.find(UserAddress, { user: { id: userId }, isDefault: true });
for (const address of existingAddresses) {
address.isDefault = false;
}
await this.em.flush();
}
// Create new address
const address = this.em.create(UserAddress, {
user,
title: dto.title,
address: dto.address,
city: dto.city,
province: dto.province,
postalCode: dto.postalCode,
latitude: dto.latitude,
longitude: dto.longitude,
phone: dto.phone,
isDefault: dto.isDefault ?? false,
});
await this.em.persistAndFlush(address);
return address;
}
async getUserAddress(userId: string, addressId: string): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, {
id: addressId,
user: { id: userId },
});
if (!address) {
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
}
return address;
}
async updateUserAddress(userId: string, addressId: string, dto: UpdateUserAddressDto): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, {
id: addressId,
user: { id: userId },
});
if (!address) {
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
}
// If setting as default, unset other default addresses
if (dto.isDefault === true) {
const existingAddresses = await this.em.find(UserAddress, {
user: { id: userId },
isDefault: true,
id: { $ne: addressId },
});
for (const existingAddress of existingAddresses) {
existingAddress.isDefault = false;
}
await this.em.flush();
}
// Update address fields
this.em.assign(address, dto);
await this.em.flush();
return address;
}
async deleteUserAddress(userId: string, addressId: string): Promise<void> {
const address = await this.em.findOne(UserAddress, {
id: addressId,
user: { id: userId },
});
if (!address) {
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
}
// Soft delete the address
address.deletedAt = new Date();
await this.em.persistAndFlush(address);
}
async setDefaultAddress(userId: string, addressId: string): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, {
id: addressId,
user: { id: userId },
});
if (!address) {
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
}
// Unset all other default addresses
const existingAddresses = await this.em.find(UserAddress, {
user: { id: userId },
isDefault: true,
id: { $ne: addressId },
});
for (const existingAddress of existingAddresses) {
existingAddress.isDefault = false;
}
// Set this address as default
address.isDefault = true;
await this.em.flush();
return address;
}
async convertScoreToWallet(userId: string, restId: string) {
return this.em.transactional(async em => {
const user = await em.findOne(User, { id: userId });
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
const shop = await em.findOne(Shop, { id: restId });
if (!shop) {
throw new NotFoundException(`Shop with ID ${restId} not found.`);
}
let walletTransaction = await em.findOne(WalletTransaction, { user: { id: userId }, shop: { id: restId } },
{ orderBy: { createdAt: 'DESC' } });
if (!walletTransaction) {
walletTransaction = em.create(WalletTransaction,
{
user: user,
shop,
balance: 0,
amount: 0,
type: WalletTransactionType.CREDIT,
reason: WalletTransactionReason.DEPOSIT
});
await em.persistAndFlush(walletTransaction);
}
// Determine how many points to convert
const pointsToConvert = walletTransaction.balance;
// Validate points to convert
if (pointsToConvert <= 0) {
throw new BadRequestException('Points to convert must be greater than 0.');
}
if (!shop.score) {
throw new BadRequestException('Shop score not found.');
}
if (!shop.score.purchaseScore) {
throw new BadRequestException('Shop purchase score not found.');
}
if (!shop.score.purchaseAmount) {
throw new BadRequestException('Shop purchase amount not found.');
}
const walletIncreaseAmount =
(Number(pointsToConvert) * Number(shop.score.purchaseAmount)) / Number(shop.score.purchaseScore);
// Convert points to wallet (1:1 ratio - can be made configurable)
const newWalletTransaction = em.create(WalletTransaction, {
user: user,
shop: shop,
amount: walletIncreaseAmount,
type: WalletTransactionType.CREDIT,
reason: WalletTransactionReason.CONVERT_SCORE_TO_WALLET,
balance: walletTransaction.balance + walletIncreaseAmount,
});
// Update user's wallet and points
walletTransaction.balance = walletTransaction.balance + walletIncreaseAmount;
em.persist([newWalletTransaction]);
await em.flush();
return user;
});
}
getUserWallet(userId: string, restId: string): Promise<WalletTransaction | null> {
return this.walletTransactionRepository.findOne({ user: { id: userId }, shop: { id: restId } },
{ orderBy: { createdAt: 'DESC' } });
}
getUserWalletTransactions(userId: string, restId: string, dto: FindWalletTransactionsDto) {
return this.walletService.getUserWalletTransactions(userId, restId, dto);
}
async createWalletTransaction(userId: string, restId: string, dto: CreateWalletTransactionDto) {
return this.em.transactional(async em => {
return this.walletService.createTransaction(em, userId, restId, dto);
});
}
}