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
+18
View File
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { SignupCompleteDto } from './dto/signup-complete.dto';
import { SignupStartDto } from './dto/signup-start.dto';
import { SignupVerifyOtpDto } from './dto/signup-verify-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@@ -37,6 +40,21 @@ export class AuthController {
return this.authService.register(dto);
}
@Post('signup/start')
startSignup(@Body() dto: SignupStartDto) {
return this.authService.startSignup(dto);
}
@Post('signup/verify-otp')
verifySignupOtp(@Body() dto: SignupVerifyOtpDto) {
return this.authService.verifySignupOtp(dto.cellNumber, dto.code);
}
@Post('signup/complete')
completeSignup(@Body() dto: SignupCompleteDto) {
return this.authService.completeSignup(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
+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(
+17
View File
@@ -0,0 +1,17 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class SignupCompleteDto {
/** One-time token from signup/verify-otp (or signup/start when SMS is off). */
@IsString()
@MinLength(16)
registrationToken!: string;
/**
* Required when creating a brand-new Meshkee account.
* Ignored when linking an existing account (password stays unchanged).
*/
@IsOptional()
@IsString()
@MinLength(8, { message: 'password must be at least 8 characters' })
password?: string;
}
+23
View File
@@ -0,0 +1,23 @@
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class SignupStartDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
/** Domain of the business website (e.g. shop-a.local). */
@IsString()
@MinLength(3)
@MaxLength(253)
domain!: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsString, Length, Matches } from 'class-validator';
export class SignupVerifyOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
}
+66 -1
View File
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
const PDF_MIME_TYPES = new Set(['application/pdf']);
/** Product catalog PDFs are capped lower than general media uploads. */
const PDF_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
@Injectable()
export class MediaService {
private readonly maxFileSizeBytes: number;
@@ -375,6 +380,14 @@ export class MediaService {
throw new BadRequestException('Uploaded file is empty');
}
const isPdf =
PDF_MIME_TYPES.has(file.mimetype) ||
file.originalname.toLowerCase().endsWith('.pdf');
if (isPdf) {
return this.uploadPdf(businessId, file, uploadedBy);
}
if (file.size > this.maxFileSizeBytes) {
throw new BadRequestException(
`File ${file.originalname} exceeds the maximum allowed size`,
@@ -409,7 +422,7 @@ export class MediaService {
}
throw new BadRequestException(
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`,
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, GIF, or PDF.`,
);
}
@@ -443,6 +456,54 @@ export class MediaService {
return this.serialize(created);
}
private async uploadPdf(
businessId: bigint,
file: Express.Multer.File,
uploadedBy: bigint,
) {
if (file.size > PDF_MAX_FILE_SIZE_BYTES) {
throw new BadRequestException(
`PDF ${file.originalname} exceeds the maximum allowed size of 2MB`,
);
}
const header = file.buffer.subarray(0, 5).toString('utf8');
if (!header.startsWith('%PDF-')) {
throw new BadRequestException(
`File ${file.originalname} is not a valid PDF`,
);
}
const fileName = `${randomUUID()}.pdf`;
const storageKey = businessMediaKey(businessId, fileName);
const contentType = 'application/pdf';
const stored = await this.storage.upload({
key: storageKey,
body: file.buffer,
contentType,
});
const created = await this.prisma.media.create({
data: {
businessId,
uploadedBy,
mediaType: MediaType.document,
storageDisk: stored.storageDisk,
storagePath: stored.storagePath,
publicUrl: stored.publicUrl,
fileName,
originalFileName: file.originalname,
mimeType: contentType,
fileSizeBytes: BigInt(file.size),
width: null,
height: null,
},
});
return this.serialize(created);
}
private contentTypeFromFormat(format?: string) {
switch (format) {
case 'jpeg':
@@ -469,6 +530,8 @@ export class MediaService {
return '.webp';
case 'image/gif':
return '.gif';
case 'application/pdf':
return '.pdf';
default:
return '.jpg';
}
@@ -493,6 +556,8 @@ export class MediaService {
return '.mp4';
case 'video/webm':
return '.webm';
case 'application/pdf':
return '.pdf';
default:
return '';
}
+10
View File
@@ -104,6 +104,11 @@ export class CreateProductDto {
@IsString()
featuredMediaId?: string;
/** Optional PDF catalog file (media id; application/pdf, max 2MB). */
@IsOptional()
@IsString()
catalogMediaId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@@ -165,6 +170,11 @@ export class UpdateProductDto {
@IsString()
featuredMediaId?: string | null;
/** Optional PDF catalog file (media id). Pass null to clear. */
@IsOptional()
@IsString()
catalogMediaId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
+57 -20
View File
@@ -43,12 +43,14 @@ function slugify(value: string): string {
type ProductWithRelations = Prisma.ProductGetPayload<{
include: {
featuredMedia: true;
catalogMedia: true;
brand: { include: { imageMedia: true } };
};
}>;
const productListInclude = {
featuredMedia: true,
catalogMedia: true,
brand: { include: { imageMedia: true } },
} satisfies Prisma.ProductInclude;
@@ -105,10 +107,7 @@ export class ProductsService {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
}),
this.prisma.product.count({ where }),
]);
@@ -140,10 +139,7 @@ export class ProductsService {
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
});
if (!product) {
@@ -168,10 +164,7 @@ export class ProductsService {
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
}),
this.prisma.product.count({ where }),
]);
@@ -338,6 +331,13 @@ export class ProductsService {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const catalogMediaId = dto.catalogMediaId
? BigInt(dto.catalogMediaId)
: null;
if (catalogMediaId) {
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
}
const galleryMediaIds = await this.resolveGalleryMediaIds(
businessId,
dto.galleryMediaIds ?? [],
@@ -363,6 +363,7 @@ export class ProductsService {
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
status,
featuredMediaId,
catalogMediaId,
brandId,
publishedAt: status === ContentStatus.published ? new Date() : null,
metadata: applySeoMetaToMetadata(
@@ -370,10 +371,7 @@ export class ProductsService {
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
},
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
});
if (dto.categoryId) {
@@ -440,6 +438,16 @@ export class ProductsService {
}
}
let catalogMediaId: bigint | null | undefined = undefined;
if (dto.catalogMediaId !== undefined) {
if (dto.catalogMediaId === null || dto.catalogMediaId === '') {
catalogMediaId = null;
} else {
catalogMediaId = BigInt(dto.catalogMediaId);
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
@@ -496,16 +504,14 @@ export class ProductsService {
: {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
...(catalogMediaId !== undefined ? { catalogMediaId } : {}),
...(brandId !== undefined ? { brandId } : {}),
...(publishedAt !== undefined ? { publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
});
if (dto.categoryId !== undefined) {
@@ -563,6 +569,13 @@ export class ProductsService {
) {
dropped.push(existing.featuredMediaId);
}
if (
catalogMediaId !== undefined &&
existing.catalogMediaId != null &&
existing.catalogMediaId !== catalogMediaId
) {
dropped.push(existing.catalogMediaId);
}
await this.mediaService.deleteUnused(businessId, dropped);
return {
@@ -590,6 +603,9 @@ export class ProductsService {
productId,
existing.featuredMediaId,
);
if (existing.catalogMediaId != null) {
mediaIds.push(existing.catalogMediaId);
}
await this.prisma.$transaction([
this.prisma.mediaAttachment.deleteMany({
@@ -718,6 +734,9 @@ export class ProductsService {
...readSeoMetaFromMetadata(metadata),
thumbnailUrl,
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
catalogMediaId: product.catalogMediaId?.toString() ?? null,
catalogUrl: product.catalogMedia?.publicUrl ?? null,
catalogFileName: product.catalogMedia?.originalFileName ?? null,
image,
thumbnail: thumbnailUrl ?? image,
images: galleryAttachments.map((item) => ({
@@ -827,6 +846,24 @@ export class ProductsService {
}
}
private async assertCatalogPdfBelongsToBusiness(
businessId: bigint,
mediaId: bigint,
) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Catalog file not found for this business');
}
if (media.mimeType !== 'application/pdf') {
throw new BadRequestException('Catalog file must be a PDF');
}
if (media.fileSizeBytes > BigInt(2 * 1024 * 1024)) {
throw new BadRequestException('Catalog PDF must be 2MB or smaller');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
+130
View File
@@ -70,6 +70,136 @@ export class RedisService {
}
}
/**
* Signup in progress: name + tenant stored until OTP proves phone ownership.
*/
async setPendingSignup(
cellNumber: string,
payload: {
firstName: string;
lastName: string;
businessId: string;
},
ttlSeconds: number,
): Promise<void> {
await this.redis.set(
`auth:pending-signup:${cellNumber}`,
JSON.stringify(payload),
'EX',
ttlSeconds,
);
}
async getPendingSignup(cellNumber: string): Promise<{
firstName: string;
lastName: string;
businessId: string;
} | null> {
const raw = await this.redis.get(`auth:pending-signup:${cellNumber}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as {
firstName?: unknown;
lastName?: unknown;
businessId?: unknown;
};
if (
typeof parsed.firstName !== 'string' ||
typeof parsed.lastName !== 'string' ||
typeof parsed.businessId !== 'string' ||
!parsed.firstName ||
!parsed.lastName ||
!parsed.businessId
) {
return null;
}
return {
firstName: parsed.firstName,
lastName: parsed.lastName,
businessId: parsed.businessId,
};
} catch {
return null;
}
}
async deletePendingSignup(cellNumber: string): Promise<void> {
await this.redis.del(`auth:pending-signup:${cellNumber}`);
}
/**
* One-time token after signup OTP: complete account create/link without re-OTP.
*/
async setSignupVerifiedToken(
token: string,
payload: {
cellNumber: string;
firstName: string;
lastName: string;
businessId: string;
existingUserId: string | null;
},
ttlSeconds: number,
): Promise<void> {
await this.redis.set(
`auth:signup-verified:${token}`,
JSON.stringify(payload),
'EX',
ttlSeconds,
);
}
async consumeSignupVerifiedToken(token: string): Promise<{
cellNumber: string;
firstName: string;
lastName: string;
businessId: string;
existingUserId: string | null;
} | null> {
const key = `auth:signup-verified:${token}`;
const result = await this.redis.multi().get(key).del(key).exec();
const raw = result?.[0]?.[1];
if (typeof raw !== 'string' || !raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
cellNumber?: unknown;
firstName?: unknown;
lastName?: unknown;
businessId?: unknown;
existingUserId?: unknown;
};
if (
typeof parsed.cellNumber !== 'string' ||
typeof parsed.firstName !== 'string' ||
typeof parsed.lastName !== 'string' ||
typeof parsed.businessId !== 'string' ||
!parsed.cellNumber ||
!parsed.firstName ||
!parsed.lastName ||
!parsed.businessId
) {
return null;
}
const existingUserId =
parsed.existingUserId === null || parsed.existingUserId === undefined
? null
: typeof parsed.existingUserId === 'string'
? parsed.existingUserId
: null;
return {
cellNumber: parsed.cellNumber,
firstName: parsed.firstName,
lastName: parsed.lastName,
businessId: parsed.businessId,
existingUserId,
};
} catch {
return null;
}
}
async setHandoffTicket(
ticket: string,
userId: string,