import { BadRequestException, ConflictException, ForbiddenException, HttpException, Injectable, ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { randomBytes } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; import { TenantService } from '../tenant/tenant.service'; import { AuthJwtPayload, AuthUser, DashboardType, UserProfile, resolvePrimaryRole, } from './auth.types'; import { ChangePasswordDto } from './dto/change-password.dto'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { SignupCompleteDto } from './dto/signup-complete.dto'; import { SignupStartDto } from './dto/signup-start.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { PermissionsService } from './permissions.service'; import { parseUserProfile } from './profile.util'; import { SmsService } from './sms.service'; import { SmsBillingService } from './sms-billing.service'; const OTP_TTL_SECONDS = 300; /** Pending cross-site password change (longer than OTP so resend still works). */ const PENDING_PASSWORD_TTL_SECONDS = 600; /** Pending signup + post-OTP registration token. */ const PENDING_SIGNUP_TTL_SECONDS = 600; const HANDOFF_TTL_SECONDS = 60; const HANDOFF_RATE_LIMIT = 10; const HANDOFF_RATE_WINDOW_SECONDS = 60; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly redis: RedisService, private readonly sms: SmsService, private readonly smsBilling: SmsBillingService, private readonly tenant: TenantService, private readonly permissions: PermissionsService, ) {} async register(dto: RegisterDto) { const business = await this.tenant.resolveBusinessByDomain(dto.domain); const passwordHash = await bcrypt.hash(dto.password, 10); const smsEnabled = this.sms.isEnabled(); const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { throw new Error('Customer role is missing. Run database migrations first.'); } const existingUser = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, include: { businessCustomers: { include: { business: { select: { id: true, name: true, nameFa: true } } }, }, }, }); const alreadyOnThisSite = existingUser?.businessCustomers.some( (item) => item.businessId === business.id, ); if (alreadyOnThisSite) { throw new ConflictException( 'This cell number is already registered on this website', ); } let passwordMatchesExisting = false; if (existingUser) { passwordMatchesExisting = await bcrypt.compare( dto.password, existingUser.passwordHash, ); if (!passwordMatchesExisting && !dto.acknowledgeExistingAccount) { const otherNames = existingUser.businessCustomers .map((item) => item.business.nameFa?.trim() || item.business.name) .filter(Boolean); const siteList = otherNames.length > 0 ? otherNames.join(', ') : 'another Meshkee website'; throw new ConflictException( `CELL_EXISTS_OTHER_SITE:${siteList}`, ); } } const linkedExistingAccount = Boolean(existingUser); // New password claimed on another site — apply only after OTP proves ownership. const wantsPasswordUpdate = linkedExistingAccount && !passwordMatchesExisting; // Hold new password in Redis until OTP; do not touch DB password or membership yet. if (existingUser && wantsPasswordUpdate && smsEnabled) { await this.redis.setPendingPasswordUpdate( dto.cellNumber, { passwordHash, businessId: business.id.toString(), }, PENDING_PASSWORD_TTL_SECONDS, ); const authUser = await this.getAuthUser(existingUser.id); return { message: 'Please verify your cell number with OTP. Your Meshkee password will be updated after verification.', smsEnabled: true, requiresOtp: true, passwordPending: true, passwordUpdated: false, user: this.serializeUser(authUser), registeredBusiness: { id: business.id, name: business.name, slug: business.slug, }, }; } const userId = await this.prisma.$transaction(async (tx) => { let userId: bigint; if (existingUser) { if (wantsPasswordUpdate) { await tx.user.update({ where: { id: existingUser.id }, data: { passwordHash }, }); } userId = existingUser.id; } else { const created = await tx.user.create({ data: { cellNumber: dto.cellNumber, passwordHash, email: dto.email, firstName: dto.firstName, lastName: dto.lastName, cellVerifiedAt: smsEnabled ? null : new Date(), }, select: { id: true }, }); userId = created.id; } await tx.businessCustomer.create({ data: { businessId: business.id, userId, }, }); const hasCustomerRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId, roleId: customerRole.id, }, }, }); if (!hasCustomerRole) { await tx.userRole.create({ data: { userId, roleId: customerRole.id, }, }); } return userId; }); const authUser = await this.getAuthUser(userId); const tokens = await this.issueTokens(authUser); return { message: wantsPasswordUpdate ? 'Joined this website. Your Meshkee password was updated to the one you just entered.' : linkedExistingAccount ? 'Joined this website with your existing Meshkee account.' : smsEnabled ? 'Registration successful. Please verify your cell number with OTP.' : 'Registration successful. SMS verification is disabled — account auto-verified.', smsEnabled, passwordUpdated: wantsPasswordUpdate, user: this.serializeUser(authUser), registeredBusiness: { id: business.id, name: business.name, slug: business.slug, }, ...tokens, }; } /** * Signup step 1: name + number. Stores pending signup and sends OTP * (OTP is allowed before the user row exists). */ async startSignup(dto: SignupStartDto) { const business = await this.tenant.resolveBusinessByDomain(dto.domain); const existingUser = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, include: { businessCustomers: { include: { business: { select: { id: true, name: true, nameFa: true } } }, }, }, }); const alreadyOnThisSite = existingUser?.businessCustomers.some( (item) => item.businessId === business.id, ); if (alreadyOnThisSite) { throw new ConflictException( 'This cell number is already registered on this website', ); } await this.redis.setPendingSignup( dto.cellNumber, { firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), businessId: business.id.toString(), }, PENDING_SIGNUP_TTL_SECONDS, ); const smsEnabled = this.sms.isEnabled(); if (!smsEnabled) { const sites = this.otherSiteNames(existingUser, business.id); const registrationToken = await this.issueSignupVerifiedToken({ cellNumber: dto.cellNumber, firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), businessId: business.id.toString(), existingUserId: existingUser ? existingUser.id.toString() : null, }); await this.redis.deletePendingSignup(dto.cellNumber); return { enabled: false, requiresOtp: false, existingAccount: Boolean(existingUser), sites, registrationToken, message: 'SMS verification is currently disabled. Continue to finish registration.', }; } await this.dispatchOtpSms(dto.cellNumber, business); return { enabled: true, requiresOtp: true, message: 'Verification code sent', expiresInSeconds: OTP_TTL_SECONDS, }; } /** * Signup step 2: verify OTP, then report whether the phone already has a * Meshkee account. Returns a one-time registrationToken for step 3. */ async verifySignupOtp(cellNumber: string, code: string) { const pending = await this.redis.getPendingSignup(cellNumber); if (!pending) { throw new BadRequestException( 'Signup session expired. Please start registration again.', ); } await this.consumeOtpCode(cellNumber, code); const businessId = BigInt(pending.businessId); const existingUser = await this.prisma.user.findUnique({ where: { cellNumber }, include: { businessCustomers: { include: { business: { select: { id: true, name: true, nameFa: true } } }, }, }, }); const alreadyOnThisSite = existingUser?.businessCustomers.some( (item) => item.businessId === businessId, ); if (alreadyOnThisSite) { await this.redis.deletePendingSignup(cellNumber); throw new ConflictException( 'This cell number is already registered on this website', ); } const sites = this.otherSiteNames(existingUser, businessId); const registrationToken = await this.issueSignupVerifiedToken({ cellNumber, firstName: pending.firstName, lastName: pending.lastName, businessId: pending.businessId, existingUserId: existingUser ? existingUser.id.toString() : null, }); await this.redis.deletePendingSignup(cellNumber); return { verified: true, existingAccount: Boolean(existingUser), sites, registrationToken, message: existingUser ? 'Phone verified. You already have a Meshkee account — your password stays the same.' : 'Phone verified. Choose a password to finish creating your account.', }; } /** * Signup step 3: create a new account (password required) or link an * existing Meshkee account (password unchanged). */ async completeSignup(dto: SignupCompleteDto) { const verified = await this.redis.consumeSignupVerifiedToken( dto.registrationToken, ); if (!verified) { throw new BadRequestException( 'Registration session expired. Please start registration again.', ); } const businessId = BigInt(verified.businessId); const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { id: true, name: true, slug: true }, }); if (!business) { throw new BadRequestException('Business not found for this registration.'); } const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { throw new Error('Customer role is missing. Run database migrations first.'); } const linkedExisting = Boolean(verified.existingUserId); if (!linkedExisting) { const password = dto.password?.trim(); if (!password || password.length < 8) { throw new BadRequestException( 'Password is required (at least 8 characters) for new accounts.', ); } } const userId = await this.prisma.$transaction(async (tx) => { let userId: bigint; if (verified.existingUserId) { userId = BigInt(verified.existingUserId); const existing = await tx.user.findUnique({ where: { id: userId }, include: { businessCustomers: { select: { businessId: true } }, }, }); if (!existing) { throw new BadRequestException( 'Existing account not found. Please start registration again.', ); } if ( existing.businessCustomers.some((item) => item.businessId === businessId) ) { throw new ConflictException( 'This cell number is already registered on this website', ); } await tx.user.update({ where: { id: userId }, data: { isActive: true, cellVerifiedAt: existing.cellVerifiedAt ?? new Date(), }, }); } else { const collision = await tx.user.findUnique({ where: { cellNumber: verified.cellNumber }, select: { id: true }, }); if (collision) { throw new ConflictException( 'This cell number is already registered. Please start registration again.', ); } const passwordHash = await bcrypt.hash(dto.password!.trim(), 10); const created = await tx.user.create({ data: { cellNumber: verified.cellNumber, passwordHash, firstName: verified.firstName, lastName: verified.lastName, cellVerifiedAt: new Date(), isActive: true, }, select: { id: true }, }); userId = created.id; } await tx.businessCustomer.create({ data: { businessId, userId, }, }); const hasCustomerRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId, roleId: customerRole.id, }, }, }); if (!hasCustomerRole) { await tx.userRole.create({ data: { userId, roleId: customerRole.id, }, }); } return userId; }); const authUser = await this.getAuthUser(userId); const tokens = await this.issueTokens(authUser); return { message: linkedExisting ? 'Joined this website with your existing Meshkee account.' : 'Registration successful.', existingAccount: linkedExisting, passwordUpdated: false, user: this.serializeUser(authUser), registeredBusiness: { id: business.id, name: business.name, slug: business.slug, }, ...tokens, }; } async login(dto: LoginDto) { const user = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, }); if (!user || !user.isActive) { throw new UnauthorizedException('Invalid cell number or password'); } const passwordValid = await bcrypt.compare(dto.password, user.passwordHash); if (!passwordValid) { throw new UnauthorizedException('Invalid cell number or password'); } if (this.sms.isEnabled() && !user.cellVerifiedAt) { throw new UnauthorizedException( 'Cell number is not verified. Please complete OTP verification.', ); } if (dto.domain?.trim()) { await this.ensureCustomerMembershipForDomain(user.id, dto.domain.trim()); } await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); const authUser = await this.getAuthUser(user.id); const tokens = await this.issueTokens(authUser); return { message: 'Login successful', user: this.serializeUser(authUser), ...tokens, }; } async refresh(refreshToken: string) { let payload: AuthJwtPayload; try { payload = await this.jwt.verifyAsync(refreshToken, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), }); } catch { throw new UnauthorizedException('Invalid or expired refresh token'); } if (payload.type !== 'refresh') { throw new UnauthorizedException('Invalid token type'); } const authUser = await this.getAuthUser(BigInt(payload.sub)); const tokens = await this.issueTokens(authUser); return { message: 'Token refreshed', user: this.serializeUser(authUser), ...tokens, }; } async createHandoff(user: AuthUser) { if (!this.canAccessBusinessDashboard(user)) { throw new ForbiddenException( 'This account cannot open the business dashboard.', ); } const allowed = await this.redis.incrementWithLimit( `sso:handoff:rate:${user.id.toString()}`, HANDOFF_RATE_LIMIT, HANDOFF_RATE_WINDOW_SECONDS, ); if (!allowed) { throw new HttpException('Too many dashboard handoff requests. Try again shortly.', 429); } const ticket = randomBytes(32).toString('hex'); await this.redis.setHandoffTicket( ticket, user.id.toString(), HANDOFF_TTL_SECONDS, ); return { ticket, expiresInSeconds: HANDOFF_TTL_SECONDS, }; } async consumeHandoff(ticket: string) { const userId = await this.redis.consumeHandoffTicket(ticket); if (!userId) { throw new UnauthorizedException('Invalid or expired dashboard handoff'); } const authUser = await this.getAuthUser(BigInt(userId)); if (!authUser || !this.canAccessBusinessDashboard(authUser)) { throw new ForbiddenException( 'This account cannot access the business dashboard.', ); } await this.prisma.user.update({ where: { id: authUser.id }, data: { lastLoginAt: new Date() }, }); const tokens = await this.issueTokens(authUser); return { message: 'Login successful', user: this.serializeUser(authUser), ...tokens, }; } async me(user: AuthUser) { return { user: this.serializeUser(user) }; } async updateProfile(user: AuthUser, dto: UpdateProfileDto) { const currentProfile = user.profile; const nextProfile: UserProfile = { ...currentProfile, about: dto.about ?? currentProfile.about, city: dto.city ?? currentProfile.city, address: dto.address ?? currentProfile.address, landline: dto.landline ?? currentProfile.landline, backupPhone: dto.backupPhone ?? currentProfile.backupPhone, postalCode: dto.postalCode ?? currentProfile.postalCode, instagram: dto.instagram ?? currentProfile.instagram, telegramId: dto.telegramId ?? currentProfile.telegramId, linkedin: dto.linkedin ?? currentProfile.linkedin, }; await this.prisma.user.update({ where: { id: user.id }, data: { firstName: dto.firstName ?? user.firstName, lastName: dto.lastName ?? user.lastName, firstNameEn: dto.firstNameEn ?? user.firstNameEn, lastNameEn: dto.lastNameEn ?? user.lastNameEn, email: dto.email ?? user.email, profile: nextProfile as object, }, }); const authUser = await this.getAuthUser(user.id); return { message: 'Profile updated successfully', user: this.serializeUser(authUser), }; } async changePassword(user: AuthUser, dto: ChangePasswordDto) { const account = await this.prisma.user.findUnique({ where: { id: user.id }, }); if (!account) { throw new UnauthorizedException('User not found'); } const passwordValid = await bcrypt.compare( dto.currentPassword, account.passwordHash, ); if (!passwordValid) { throw new BadRequestException('Current password is incorrect'); } if (dto.currentPassword === dto.newPassword) { throw new BadRequestException( 'New password must be different from the current password', ); } const passwordHash = await bcrypt.hash(dto.newPassword, 10); await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash }, }); return { message: 'Password changed successfully' }; } async sendOtp(cellNumber: string, domain: string) { if (!this.sms.isEnabled()) { return { enabled: false, message: 'SMS verification is currently disabled. Register and login work without OTP.', }; } const user = await this.prisma.user.findUnique({ where: { cellNumber }, }); if (!user) { throw new UnauthorizedException('Cell number is not registered'); } const host = domain?.trim(); if (!host) { throw new BadRequestException( 'Domain is required to send SMS (prepaid balance is billed per business).', ); } const business = await this.tenant.resolveBusinessByDomain(host); await this.dispatchOtpSms(cellNumber, business); return { enabled: true, message: 'Verification code sent', expiresInSeconds: OTP_TTL_SECONDS, }; } async verifyOtp(cellNumber: string, code: string) { const user = await this.consumeOtp(cellNumber, code); const smsEnabled = this.sms.isEnabled(); if (!user.cellVerifiedAt) { await this.prisma.user.update({ where: { id: user.id }, data: { cellVerifiedAt: new Date() }, }); } return { enabled: smsEnabled, verified: true, message: smsEnabled ? 'Cell number verified successfully' : 'SMS verification is disabled — cell number marked as verified.', }; } async loginWithOtp(cellNumber: string, code: string, domain?: string) { const user = await this.consumeOtp(cellNumber, code); const pending = await this.redis.consumePendingPasswordUpdate(cellNumber); const passwordUpdated = Boolean(pending?.passwordHash); await this.prisma.user.update({ where: { id: user.id }, data: { // OTP proves phone ownership — reactivate accounts left inactive (e.g. unfinished signup). isActive: true, cellVerifiedAt: user.cellVerifiedAt ?? new Date(), lastLoginAt: new Date(), ...(pending?.passwordHash ? { passwordHash: pending.passwordHash } : {}), }, }); if (pending?.businessId) { await this.ensureCustomerMembership(user.id, BigInt(pending.businessId)); } if (domain?.trim()) { await this.ensureCustomerMembershipForDomain(user.id, domain.trim()); } const authUser = await this.getAuthUser(user.id); const tokens = await this.issueTokens(authUser); return { message: passwordUpdated ? 'Login successful. Your Meshkee password was updated.' : 'Login successful', passwordUpdated, user: this.serializeUser(authUser), ...tokens, }; } async resetPassword(cellNumber: string, code: string, newPassword: string) { const user = await this.consumeOtp(cellNumber, code); const passwordHash = await bcrypt.hash(newPassword, 10); await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash, isActive: true, cellVerifiedAt: user.cellVerifiedAt ?? new Date(), }, }); return { message: 'Password reset successfully. You can now sign in with your new password.', }; } /** Validates OTP (or skips when SMS is disabled) and returns the user. */ private async consumeOtp(cellNumber: string, code: string) { const user = await this.prisma.user.findUnique({ where: { cellNumber }, }); if (!user) { throw new UnauthorizedException('Cell number is not registered'); } await this.consumeOtpCode(cellNumber, code); return user; } /** Validates and consumes the OTP code without requiring a user row. */ private async consumeOtpCode(cellNumber: string, code: string) { if (!this.sms.isEnabled()) { return; } const storedCode = await this.redis.getOtp(cellNumber); if (!storedCode || storedCode !== code) { throw new UnauthorizedException('Invalid or expired verification code'); } await this.redis.deleteOtp(cellNumber); } private async dispatchOtpSms( cellNumber: string, business: { id: bigint; name: string | null; nameFa: string | null }, ) { const businessNameFa = business.nameFa?.trim() || business.name?.trim() || undefined; const code = this.generateOtpCode(); const brand = businessNameFa?.trim(); const message = brand ? `کد تایید شما: ${code}\n${brand}` : `کد تایید شما: ${code}`; const charged = await this.smsBilling.charge(business.id, message, 1, 'auth'); await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS); try { await this.sms.sendMessage(cellNumber, message); } catch (err) { await this.smsBilling.refund(business.id, charged, 1, 'auth'); if (err instanceof HttpException) { throw err; } throw new ServiceUnavailableException('Unable to send SMS'); } } private otherSiteNames( existingUser: | { businessCustomers: { businessId: bigint; business: { name: string; nameFa: string | null }; }[]; } | null | undefined, excludeBusinessId: bigint, ): string[] { if (!existingUser) return []; return existingUser.businessCustomers .filter((item) => item.businessId !== excludeBusinessId) .map((item) => item.business.nameFa?.trim() || item.business.name) .filter(Boolean); } private async issueSignupVerifiedToken(payload: { cellNumber: string; firstName: string; lastName: string; businessId: string; existingUserId: string | null; }): Promise { const registrationToken = randomBytes(32).toString('hex'); await this.redis.setSignupVerifiedToken( registrationToken, payload, PENDING_SIGNUP_TTL_SECONDS, ); return registrationToken; } private async ensureCustomerMembershipForDomain( userId: bigint, domain: string, ) { const business = await this.tenant.resolveBusinessByDomain(domain); await this.ensureCustomerMembership(userId, business.id); } private async ensureCustomerMembership(userId: bigint, businessId: bigint) { const existing = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId }, }, }); if (!existing) { await this.prisma.businessCustomer.create({ data: { businessId, userId, }, }); } const customerRole = await this.prisma.role.findUnique({ where: { slug: 'customer' }, }); if (!customerRole) { return; } const hasCustomerRole = await this.prisma.userRole.findUnique({ where: { userId_roleId: { userId, roleId: customerRole.id, }, }, }); if (!hasCustomerRole) { await this.prisma.userRole.create({ data: { userId, roleId: customerRole.id, }, }); } } private async getAuthUser(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } }, businessUsers: { include: { business: true } }, businessCustomers: { include: { business: true } }, }, }); if (!user) { throw new UnauthorizedException('User not found'); } const roles = user.userRoles.map((ur) => ur.role.slug); const businesses = await this.permissions.getBusinessMemberships(user.id); const customerBusinesses = user.businessCustomers.map((bc) => ({ id: bc.business.id, name: bc.business.name, slug: bc.business.slug, })); return { id: user.id, cellNumber: user.cellNumber, email: user.email, firstName: user.firstName, lastName: user.lastName, firstNameEn: user.firstNameEn, lastNameEn: user.lastNameEn, cellVerifiedAt: user.cellVerifiedAt, roles, dashboard: this.resolveDashboard(roles, businesses.length), profile: parseUserProfile(user.profile), businesses, customerBusinesses, }; } private resolveDashboard(roles: string[], businessCount: number): DashboardType { if (roles.includes('super_admin')) { return 'super_admin'; } if ( roles.includes('business_owner') || roles.includes('business_staff') || roles.includes('owner') || businessCount > 0 ) { return 'business'; } return 'customer'; } private canAccessBusinessDashboard(user: AuthUser): boolean { if (user.roles.includes('super_admin')) { return true; } return user.businesses.length > 0; } private async issueTokens(user: AuthUser) { const accessPayload: AuthJwtPayload = { sub: user.id.toString(), cellNumber: user.cellNumber, roles: user.roles, dashboard: user.dashboard, type: 'access', }; const refreshPayload: AuthJwtPayload = { sub: user.id.toString(), cellNumber: user.cellNumber, roles: user.roles, dashboard: user.dashboard, type: 'refresh', }; const accessExpiresIn = this.config.get('JWT_ACCESS_EXPIRES_IN', '15m'); const refreshExpiresIn = this.config.get('JWT_REFRESH_EXPIRES_IN', '7d'); const [accessToken, refreshToken] = await Promise.all([ this.jwt.signAsync(accessPayload, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`, }), this.jwt.signAsync(refreshPayload, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: refreshExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`, }), ]); return { accessToken, refreshToken }; } private serializeUser(user: AuthUser) { const primaryRole = resolvePrimaryRole(user.roles); return { id: user.id, cellNumber: user.cellNumber, email: user.email, firstName: user.firstName, lastName: user.lastName, firstNameEn: user.firstNameEn, lastNameEn: user.lastNameEn, cellVerifiedAt: user.cellVerifiedAt, roles: user.roles, dashboard: user.dashboard, primaryRole: primaryRole.slug, roleLabel: primaryRole.label, isSuperAdmin: primaryRole.isSuperAdmin, profile: user.profile, businesses: user.businesses, customerBusinesses: user.customerBusinesses, }; } private generateOtpCode(): string { return Math.floor(100000 + Math.random() * 900000).toString(); } }