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:
Alireza Hassani
2026-09-27 08:57:08 +03:30
co-authored by Cursor
parent 4a9e10e614
commit 1a49576752
11 changed files with 699 additions and 43 deletions
+338 -22
View File
@@ -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(