diff --git a/src/modules/admin/entities/admin.entity.ts b/src/modules/admin/entities/admin.entity.ts index 27c99e6..a9d8a54 100644 --- a/src/modules/admin/entities/admin.entity.ts +++ b/src/modules/admin/entities/admin.entity.ts @@ -1,6 +1,7 @@ import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; import { AdminRole } from './adminRole.entity'; +import { normalizePhone } from '../../utils/phone.util'; @Entity({ tableName: 'admins' }) export class Admin extends BaseEntity { @@ -10,8 +11,16 @@ export class Admin extends BaseEntity { @Property({ nullable: true }) lastName?: string; + private _phone!: string; + @Property({ unique: true }) - phone!: string; + get phone(): string { + return this._phone; + } + + set phone(value: string) { + this._phone = normalizePhone(value); + } // Add the new role property @OneToMany(() => AdminRole, adminRole => adminRole.admin, { diff --git a/src/modules/admin/providers/admin.service.ts b/src/modules/admin/providers/admin.service.ts index ed38e79..7d9fbd3 100644 --- a/src/modules/admin/providers/admin.service.ts +++ b/src/modules/admin/providers/admin.service.ts @@ -9,6 +9,7 @@ import { CacheService } from '../../utils/cache.service'; import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto'; import { UpdateAdminDto } from '../dto/update-admin.dto'; import { AdminRole } from '../entities/adminRole.entity'; +import { normalizePhone } from '../../utils/phone.util'; @Injectable() export class AdminService { @@ -22,7 +23,8 @@ export class AdminService { ) {} async findByPhone(phone: string): Promise { - return this.adminRepository.findOne({ phone }); + const normalizedPhone = normalizePhone(phone); + return this.adminRepository.findOne({ phone: normalizedPhone }); } async findById(adminId: string, restId: string): Promise { @@ -39,13 +41,14 @@ export class AdminService { async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) { const { phone, firstName, lastName, roleId } = dto; + const normalizedPhone = normalizePhone(phone); let admin: Admin | null = null; admin = await this.adminRepository.findOne({ - phone, + phone: normalizedPhone, }); if (!admin) { admin = this.adminRepository.create({ - phone, + phone: normalizedPhone, firstName, lastName, }); @@ -123,11 +126,12 @@ export class AdminService { admin.lastName = rest.lastName; } if (rest.phone !== undefined && rest.phone !== admin.phone) { - const exists = await this.adminRepository.findOne({ phone: rest.phone }); + const normalizedPhone = normalizePhone(rest.phone); + const exists = await this.adminRepository.findOne({ phone: normalizedPhone }); if (exists) { throw new ConflictException('This Phone Number is already taken'); } - admin.phone = rest.phone; + admin.phone = normalizedPhone; } // Update role if roleId is provided diff --git a/src/modules/admin/repositories/admin.repository.ts b/src/modules/admin/repositories/admin.repository.ts index cf507b5..99066bc 100644 --- a/src/modules/admin/repositories/admin.repository.ts +++ b/src/modules/admin/repositories/admin.repository.ts @@ -3,6 +3,7 @@ import { Admin } from '../entities/admin.entity'; import { AdminRole } from '../entities/adminRole.entity'; import { RestRepository } from '../../restaurants/repositories/rest.repository'; import { Injectable } from '@nestjs/common'; +import { normalizePhone } from '../../utils/phone.util'; @Injectable() export class AdminRepository extends EntityRepository { @@ -14,6 +15,7 @@ export class AdminRepository extends EntityRepository { } async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise { + const normalizedPhone = normalizePhone(phone); // First, find the restaurant by slug using the same repository as auth service const restaurant = await this.restRepository.findOne({ slug }); if (!restaurant) { @@ -24,7 +26,7 @@ export class AdminRepository extends EntityRepository { const adminRole = await this.em.findOne( AdminRole, { - admin: { phone }, + admin: { phone: normalizedPhone }, restaurant: { id: restaurant.id }, }, { populate: ['admin', 'admin.roles', 'role', 'role.permissions', 'restaurant'] }, diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index c7c05df..fe8ea6a 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -11,6 +11,7 @@ import { AdminRepository } from 'src/modules/admin/repositories/admin.repository import { AdminLoginTransformer } from '../transformers/admin-login.transformer'; import { UserLoginTransformer } from '../transformers/user-login.transformer'; import { ConfigService } from '@nestjs/config'; +import { normalizePhone } from '../../utils/phone.util'; @Injectable() export class AuthService { @@ -36,24 +37,26 @@ export class AuthService { async requestOtp(dto: RequestOtpDto, isAdmin: boolean) { const { phone, slug } = dto; + const normalizedPhone = normalizePhone(phone); const code = this.generateOtpCode(); - const key = isAdmin ? this.adminOtpKey(slug, phone) : this.userOtpKey(slug, phone); + const key = isAdmin ? this.adminOtpKey(slug, normalizedPhone) : this.userOtpKey(slug, normalizedPhone); await this.cacheService.set(key, code, this.OTP_EXPIRATION_TIME); - // await this.smsService.sendOtp(phone, code); + // await this.smsService.sendOtp(normalizedPhone, code); return { code }; } async verifyOtp(phone: string, slug: string, code: string) { - const key = this.userOtpKey(slug, phone); + const normalizedPhone = normalizePhone(phone); + const key = this.userOtpKey(slug, normalizedPhone); const cachedCode = await this.cacheService.get(key); if (!cachedCode) throw new BadRequestException('OTP expired or not found'); if (cachedCode !== code) throw new BadRequestException('Invalid OTP'); await this.cacheService.del(key); - const user = await this.userService.findOrCreateByPhone(phone); + const user = await this.userService.findOrCreateByPhone(normalizedPhone); const rest = await this.restRepository.findOne({ slug }); @@ -69,7 +72,8 @@ export class AuthService { } async verifyOtpAdmin(phone: string, slug: string, code: string) { - const key = this.adminOtpKey(slug, phone); + const normalizedPhone = normalizePhone(phone); + const key = this.adminOtpKey(slug, normalizedPhone); const cachedCode = await this.cacheService.get(key); if (!cachedCode) throw new BadRequestException('OTP expired or not found'); @@ -77,7 +81,7 @@ export class AuthService { if (cachedCode !== code) throw new BadRequestException('Invalid OTP'); await this.cacheService.del(key); - const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(phone, slug); + const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug); if (!admin) { throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND); diff --git a/src/modules/coupons/providers/coupon.service.ts b/src/modules/coupons/providers/coupon.service.ts index fc88f09..a658711 100644 --- a/src/modules/coupons/providers/coupon.service.ts +++ b/src/modules/coupons/providers/coupon.service.ts @@ -235,7 +235,7 @@ export class CouponService { } } - generateShortCode(length = 8) { + generateShortCode(restaurantSlug: string, length = 5) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const bytes = randomBytes(length); let result = ''; @@ -243,12 +243,16 @@ export class CouponService { for (let i = 0; i < bytes.length; i++) { result += chars[bytes[i] % chars.length]; } - - return result; + const prefix = restaurantSlug.slice(0, 2); + return prefix + '-' + result; } async generateCouponCode(restId: string): Promise { - const code = this.generateShortCode(8); + const restaurant = await this.restRepository.findOne({ id: restId }); + if (!restaurant) { + throw new NotFoundException(RestMessage.NOT_FOUND); + } + const code = this.generateShortCode(restaurant.slug); const existing = await this.couponRepository.findOne({ code, restaurant: { id: restId } }); if (existing) return this.generateCouponCode(restId); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 798a60c..a3fdabf 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -2,6 +2,7 @@ import { Entity, Property, OneToMany, Collection, Cascade } from '@mikro-orm/cor import { BaseEntity } from '../../../common/entities/base.entity'; import { UserAddress } from './user-address.entity'; import { Order } from 'src/modules/orders/entities/order.entity'; +import { normalizePhone } from '../../utils/phone.util'; @Entity({ tableName: 'users' }) export class User extends BaseEntity { @@ -14,8 +15,16 @@ export class User extends BaseEntity { @Property({ nullable: true }) lastName?: string; + private _phone!: string; + @Property({ unique: true }) - phone!: string; + get phone(): string { + return this._phone; + } + + set phone(value: string) { + this._phone = normalizePhone(value); + } @Property({ default: null, type: 'date' }) birthDate!: Date; diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 2308ee9..1c37753 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -9,6 +9,7 @@ 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'; @Injectable() export class UserService { @@ -18,13 +19,14 @@ export class UserService { ) {} async findOrCreateByPhone(phone: string): Promise { - let user = await this.userRepository.findOne({ phone }); + 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, - firstName: phone, + phone: normalizedPhone, + firstName: normalizedPhone, // keep dates undefined (no value) — cast through unknown to satisfy TS typing birthDate: undefined, marriageDate: undefined, @@ -54,7 +56,8 @@ export class UserService { // } async findByPhone(phone: string): Promise { - return this.userRepository.findOne({ phone }); + const normalizedPhone = normalizePhone(phone); + return this.userRepository.findOne({ phone: normalizedPhone }); } async findById(id: string): Promise { @@ -62,9 +65,10 @@ export class UserService { } async create(phone: string): Promise { + const normalizedPhone = normalizePhone(phone); const _raw = { - phone, - firstName: phone, + phone: normalizedPhone, + firstName: normalizedPhone, birthDate: undefined, marriageDate: undefined, wallet: 0, @@ -117,11 +121,15 @@ export class UserService { // 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 } }] : []), ]; } diff --git a/src/modules/utils/phone.util.ts b/src/modules/utils/phone.util.ts new file mode 100644 index 0000000..6efea38 --- /dev/null +++ b/src/modules/utils/phone.util.ts @@ -0,0 +1,31 @@ +/** + * Normalizes Iranian phone numbers to a standard format: 9120000001 + * Removes +98 prefix, leading 0, and any spaces/dashes + * @param phone - The phone number to normalize + * @returns Normalized phone number (e.g., 9120000001) + */ +export function normalizePhone(phone: string): string { + if (!phone) { + return phone; + } + + // Remove all spaces, dashes, and parentheses + let normalized = phone.replace(/[\s\-\(\)]/g, ''); + + // Remove +98 prefix (Iran country code) + if (normalized.startsWith('+98')) { + normalized = normalized.substring(3); + } else if (normalized.startsWith('0098')) { + normalized = normalized.substring(4); + } else if (normalized.startsWith('98')) { + normalized = normalized.substring(2); + } + + // Remove leading 0 + if (normalized.startsWith('0')) { + normalized = normalized.substring(1); + } + + return normalized; +} + diff --git a/src/seeders/users.seeder.ts b/src/seeders/users.seeder.ts index 2a8508b..32b07b9 100644 --- a/src/seeders/users.seeder.ts +++ b/src/seeders/users.seeder.ts @@ -3,6 +3,7 @@ import { User } from '../modules/users/entities/user.entity'; import { UserAddress } from '../modules/users/entities/user-address.entity'; import { usersData } from './data/users.data'; import { userAddressesData } from './data/user-addresses.data'; +import { normalizePhone } from '../modules/utils/phone.util'; export class UsersSeeder { async run(em: EntityManager): Promise> { @@ -10,10 +11,11 @@ export class UsersSeeder { // Create users for (const userData of usersData) { - let user = await em.findOne(User, { phone: userData.phone }); + const normalizedPhone = normalizePhone(userData.phone); + let user = await em.findOne(User, { phone: normalizedPhone }); if (!user) { user = em.create(User, { - phone: userData.phone, + phone: normalizedPhone, // Use normalized phone firstName: userData.firstName, lastName: userData.lastName, birthDate: new Date(userData.birthDate), @@ -25,7 +27,7 @@ export class UsersSeeder { }); em.persist(user); } - usersMap.set(userData.phone, user); + usersMap.set(normalizedPhone, user); // Use normalized phone as key } await em.flush();