normalize phone

This commit is contained in:
2025-12-06 22:42:26 +03:30
parent 3978065400
commit 01631c147a
9 changed files with 100 additions and 27 deletions
+10 -1
View File
@@ -1,6 +1,7 @@
import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core'; import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { AdminRole } from './adminRole.entity'; import { AdminRole } from './adminRole.entity';
import { normalizePhone } from '../../utils/phone.util';
@Entity({ tableName: 'admins' }) @Entity({ tableName: 'admins' })
export class Admin extends BaseEntity { export class Admin extends BaseEntity {
@@ -10,8 +11,16 @@ export class Admin extends BaseEntity {
@Property({ nullable: true }) @Property({ nullable: true })
lastName?: string; lastName?: string;
private _phone!: string;
@Property({ unique: true }) @Property({ unique: true })
phone!: string; get phone(): string {
return this._phone;
}
set phone(value: string) {
this._phone = normalizePhone(value);
}
// Add the new role property // Add the new role property
@OneToMany(() => AdminRole, adminRole => adminRole.admin, { @OneToMany(() => AdminRole, adminRole => adminRole.admin, {
+9 -5
View File
@@ -9,6 +9,7 @@ import { CacheService } from '../../utils/cache.service';
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto'; import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
import { UpdateAdminDto } from '../dto/update-admin.dto'; import { UpdateAdminDto } from '../dto/update-admin.dto';
import { AdminRole } from '../entities/adminRole.entity'; import { AdminRole } from '../entities/adminRole.entity';
import { normalizePhone } from '../../utils/phone.util';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@@ -22,7 +23,8 @@ export class AdminService {
) {} ) {}
async findByPhone(phone: string): Promise<Admin | null> { async findByPhone(phone: string): Promise<Admin | null> {
return this.adminRepository.findOne({ phone }); const normalizedPhone = normalizePhone(phone);
return this.adminRepository.findOne({ phone: normalizedPhone });
} }
async findById(adminId: string, restId: string): Promise<Admin | null> { async findById(adminId: string, restId: string): Promise<Admin | null> {
@@ -39,13 +41,14 @@ export class AdminService {
async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) { async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) {
const { phone, firstName, lastName, roleId } = dto; const { phone, firstName, lastName, roleId } = dto;
const normalizedPhone = normalizePhone(phone);
let admin: Admin | null = null; let admin: Admin | null = null;
admin = await this.adminRepository.findOne({ admin = await this.adminRepository.findOne({
phone, phone: normalizedPhone,
}); });
if (!admin) { if (!admin) {
admin = this.adminRepository.create({ admin = this.adminRepository.create({
phone, phone: normalizedPhone,
firstName, firstName,
lastName, lastName,
}); });
@@ -123,11 +126,12 @@ export class AdminService {
admin.lastName = rest.lastName; admin.lastName = rest.lastName;
} }
if (rest.phone !== undefined && rest.phone !== admin.phone) { 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) { if (exists) {
throw new ConflictException('This Phone Number is already taken'); throw new ConflictException('This Phone Number is already taken');
} }
admin.phone = rest.phone; admin.phone = normalizedPhone;
} }
// Update role if roleId is provided // Update role if roleId is provided
@@ -3,6 +3,7 @@ import { Admin } from '../entities/admin.entity';
import { AdminRole } from '../entities/adminRole.entity'; import { AdminRole } from '../entities/adminRole.entity';
import { RestRepository } from '../../restaurants/repositories/rest.repository'; import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { normalizePhone } from '../../utils/phone.util';
@Injectable() @Injectable()
export class AdminRepository extends EntityRepository<Admin> { export class AdminRepository extends EntityRepository<Admin> {
@@ -14,6 +15,7 @@ export class AdminRepository extends EntityRepository<Admin> {
} }
async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise<Admin | null> { async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise<Admin | null> {
const normalizedPhone = normalizePhone(phone);
// First, find the restaurant by slug using the same repository as auth service // First, find the restaurant by slug using the same repository as auth service
const restaurant = await this.restRepository.findOne({ slug }); const restaurant = await this.restRepository.findOne({ slug });
if (!restaurant) { if (!restaurant) {
@@ -24,7 +26,7 @@ export class AdminRepository extends EntityRepository<Admin> {
const adminRole = await this.em.findOne( const adminRole = await this.em.findOne(
AdminRole, AdminRole,
{ {
admin: { phone }, admin: { phone: normalizedPhone },
restaurant: { id: restaurant.id }, restaurant: { id: restaurant.id },
}, },
{ populate: ['admin', 'admin.roles', 'role', 'role.permissions', 'restaurant'] }, { populate: ['admin', 'admin.roles', 'role', 'role.permissions', 'restaurant'] },
+10 -6
View File
@@ -11,6 +11,7 @@ import { AdminRepository } from 'src/modules/admin/repositories/admin.repository
import { AdminLoginTransformer } from '../transformers/admin-login.transformer'; import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
import { UserLoginTransformer } from '../transformers/user-login.transformer'; import { UserLoginTransformer } from '../transformers/user-login.transformer';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { normalizePhone } from '../../utils/phone.util';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -36,24 +37,26 @@ export class AuthService {
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) { async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
const { phone, slug } = dto; const { phone, slug } = dto;
const normalizedPhone = normalizePhone(phone);
const code = this.generateOtpCode(); 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.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
// await this.smsService.sendOtp(phone, code); // await this.smsService.sendOtp(normalizedPhone, code);
return { code }; return { code };
} }
async verifyOtp(phone: string, slug: string, code: string) { 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); const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found'); if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP'); if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key); 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 }); const rest = await this.restRepository.findOne({ slug });
@@ -69,7 +72,8 @@ export class AuthService {
} }
async verifyOtpAdmin(phone: string, slug: string, code: string) { 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); const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found'); 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'); if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key); await this.cacheService.del(key);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(phone, slug); const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
if (!admin) { if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND); throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
@@ -235,7 +235,7 @@ export class CouponService {
} }
} }
generateShortCode(length = 8) { generateShortCode(restaurantSlug: string, length = 5) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = randomBytes(length); const bytes = randomBytes(length);
let result = ''; let result = '';
@@ -243,12 +243,16 @@ export class CouponService {
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
result += chars[bytes[i] % chars.length]; result += chars[bytes[i] % chars.length];
} }
const prefix = restaurantSlug.slice(0, 2);
return result; return prefix + '-' + result;
} }
async generateCouponCode(restId: string): Promise<string> { async generateCouponCode(restId: string): Promise<string> {
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 } }); const existing = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
if (existing) return this.generateCouponCode(restId); if (existing) return this.generateCouponCode(restId);
+10 -1
View File
@@ -2,6 +2,7 @@ import { Entity, Property, OneToMany, Collection, Cascade } from '@mikro-orm/cor
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { UserAddress } from './user-address.entity'; import { UserAddress } from './user-address.entity';
import { Order } from 'src/modules/orders/entities/order.entity'; import { Order } from 'src/modules/orders/entities/order.entity';
import { normalizePhone } from '../../utils/phone.util';
@Entity({ tableName: 'users' }) @Entity({ tableName: 'users' })
export class User extends BaseEntity { export class User extends BaseEntity {
@@ -14,8 +15,16 @@ export class User extends BaseEntity {
@Property({ nullable: true }) @Property({ nullable: true })
lastName?: string; lastName?: string;
private _phone!: string;
@Property({ unique: true }) @Property({ unique: true })
phone!: string; get phone(): string {
return this._phone;
}
set phone(value: string) {
this._phone = normalizePhone(value);
}
@Property({ default: null, type: 'date' }) @Property({ default: null, type: 'date' })
birthDate!: Date; birthDate!: Date;
+14 -6
View File
@@ -9,6 +9,7 @@ import { CreateUserAddressDto } from './dto/create-user-address.dto';
import { UpdateUserAddressDto } from './dto/update-user-address.dto'; import { UpdateUserAddressDto } from './dto/update-user-address.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { UserRepository } from './repositories/user.repository'; import { UserRepository } from './repositories/user.repository';
import { normalizePhone } from '../utils/phone.util';
@Injectable() @Injectable()
export class UserService { export class UserService {
@@ -18,13 +19,14 @@ export class UserService {
) {} ) {}
async findOrCreateByPhone(phone: string): Promise<User> { async findOrCreateByPhone(phone: string): Promise<User> {
let user = await this.userRepository.findOne({ phone }); const normalizedPhone = normalizePhone(phone);
let user = await this.userRepository.findOne({ phone: normalizedPhone });
if (!user) { if (!user) {
// firstName is required on the entity; use phone as a sensible default // firstName is required on the entity; use phone as a sensible default
const _raw = { const _raw = {
phone, phone: normalizedPhone,
firstName: phone, firstName: normalizedPhone,
// keep dates undefined (no value) — cast through unknown to satisfy TS typing // keep dates undefined (no value) — cast through unknown to satisfy TS typing
birthDate: undefined, birthDate: undefined,
marriageDate: undefined, marriageDate: undefined,
@@ -54,7 +56,8 @@ export class UserService {
// } // }
async findByPhone(phone: string): Promise<User | null> { async findByPhone(phone: string): Promise<User | null> {
return this.userRepository.findOne({ phone }); const normalizedPhone = normalizePhone(phone);
return this.userRepository.findOne({ phone: normalizedPhone });
} }
async findById(id: string): Promise<User | null> { async findById(id: string): Promise<User | null> {
@@ -62,9 +65,10 @@ export class UserService {
} }
async create(phone: string): Promise<User> { async create(phone: string): Promise<User> {
const normalizedPhone = normalizePhone(phone);
const _raw = { const _raw = {
phone, phone: normalizedPhone,
firstName: phone, firstName: normalizedPhone,
birthDate: undefined, birthDate: undefined,
marriageDate: undefined, marriageDate: undefined,
wallet: 0, wallet: 0,
@@ -117,11 +121,15 @@ export class UserService {
// 4. Add 'search' logic (case-insensitive) // 4. Add 'search' logic (case-insensitive)
if (search) { if (search) {
const searchPattern = `%${search}%`; const searchPattern = `%${search}%`;
const normalizedSearch = normalizePhone(search);
const normalizedSearchPattern = `%${normalizedSearch}%`;
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default) // $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 = [ where.$or = [
{ firstName: { $ilike: searchPattern } }, { firstName: { $ilike: searchPattern } },
{ lastName: { $ilike: searchPattern } }, { lastName: { $ilike: searchPattern } },
{ phone: { $ilike: searchPattern } }, { phone: { $ilike: searchPattern } },
...(normalizedSearch !== search ? [{ phone: { $ilike: normalizedSearchPattern } }] : []),
]; ];
} }
+31
View File
@@ -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;
}
+5 -3
View File
@@ -3,6 +3,7 @@ import { User } from '../modules/users/entities/user.entity';
import { UserAddress } from '../modules/users/entities/user-address.entity'; import { UserAddress } from '../modules/users/entities/user-address.entity';
import { usersData } from './data/users.data'; import { usersData } from './data/users.data';
import { userAddressesData } from './data/user-addresses.data'; import { userAddressesData } from './data/user-addresses.data';
import { normalizePhone } from '../modules/utils/phone.util';
export class UsersSeeder { export class UsersSeeder {
async run(em: EntityManager): Promise<Map<string, User>> { async run(em: EntityManager): Promise<Map<string, User>> {
@@ -10,10 +11,11 @@ export class UsersSeeder {
// Create users // Create users
for (const userData of usersData) { 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) { if (!user) {
user = em.create(User, { user = em.create(User, {
phone: userData.phone, phone: normalizedPhone, // Use normalized phone
firstName: userData.firstName, firstName: userData.firstName,
lastName: userData.lastName, lastName: userData.lastName,
birthDate: new Date(userData.birthDate), birthDate: new Date(userData.birthDate),
@@ -25,7 +27,7 @@ export class UsersSeeder {
}); });
em.persist(user); em.persist(user);
} }
usersMap.set(userData.phone, user); usersMap.set(normalizedPhone, user); // Use normalized phone as key
} }
await em.flush(); await em.flush();