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>
This commit is contained in:
co-authored by
Cursor
parent
4a9e10e614
commit
1a49576752
+338
-22
@@ -24,6 +24,8 @@ import {
|
||||
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';
|
||||
@@ -33,6 +35,8 @@ 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;
|
||||
@@ -212,6 +216,271 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
@@ -430,26 +699,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
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');
|
||||
}
|
||||
await this.dispatchOtpSms(cellNumber, business);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -545,8 +795,14 @@ export class AuthService {
|
||||
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 user;
|
||||
return;
|
||||
}
|
||||
|
||||
const storedCode = await this.redis.getOtp(cellNumber);
|
||||
@@ -555,7 +811,67 @@ export class AuthService {
|
||||
}
|
||||
|
||||
await this.redis.deleteOtp(cellNumber);
|
||||
return user;
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user