Files
backend/src/auth/auth.service.ts
T
Alireza HassaniandCursor 1a49576752 Add OTP-first signup and product catalog PDF uploads.
Signup verifies phone before password; existing Meshkee accounts keep their password. Products can attach an optional PDF catalog (max 2MB) via Pars Pack media.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 08:57:08 +03:30

1049 lines
30 KiB
TypeScript

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<AuthJwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('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<string> {
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<AuthUser> {
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<string>('JWT_ACCESS_EXPIRES_IN', '15m');
const refreshExpiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(accessPayload, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
}),
this.jwt.signAsync(refreshPayload, {
secret: this.config.getOrThrow<string>('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();
}
}