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>
1049 lines
30 KiB
TypeScript
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();
|
|
}
|
|
}
|