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
@@ -0,0 +1,21 @@
|
|||||||
|
-- Product catalog PDF (optional document on products) + media_type.document
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE media_type ADD VALUE IF NOT EXISTS 'document';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS catalog_media_id BIGINT NULL;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD CONSTRAINT products_catalog_media_id_fkey
|
||||||
|
FOREIGN KEY (catalog_media_id) REFERENCES media (id) ON UPDATE NO ACTION;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_catalog_media_id
|
||||||
|
ON products (catalog_media_id);
|
||||||
@@ -393,6 +393,7 @@ model Media {
|
|||||||
instructions instructions[]
|
instructions instructions[]
|
||||||
workshops workshops[]
|
workshops workshops[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
|
catalogProducts Product[] @relation("ProductCatalogMedia")
|
||||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_image_slot_items website_image_slot_items[]
|
website_image_slot_items website_image_slot_items[]
|
||||||
website_slider_slides website_slider_slides[]
|
website_slider_slides website_slider_slides[]
|
||||||
@@ -524,6 +525,7 @@ model Product {
|
|||||||
stockQuantity Int? @map("stock_quantity")
|
stockQuantity Int? @map("stock_quantity")
|
||||||
status ContentStatus @default(draft)
|
status ContentStatus @default(draft)
|
||||||
featuredMediaId BigInt? @map("featured_media_id")
|
featuredMediaId BigInt? @map("featured_media_id")
|
||||||
|
catalogMediaId BigInt? @map("catalog_media_id")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
@@ -541,6 +543,7 @@ model Product {
|
|||||||
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
|
||||||
shoppingCardItems ShoppingCardItem[]
|
shoppingCardItems ShoppingCardItem[]
|
||||||
storeItem StoreItem[]
|
storeItem StoreItem[]
|
||||||
|
|
||||||
@@ -549,6 +552,7 @@ model Product {
|
|||||||
@@index([brandId], map: "idx_products_brand_id")
|
@@index([brandId], map: "idx_products_brand_id")
|
||||||
@@index([businessId, status], map: "idx_products_business_status")
|
@@index([businessId, status], map: "idx_products_business_status")
|
||||||
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
||||||
|
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1683,6 +1687,7 @@ model UserProduct {
|
|||||||
enum MediaType {
|
enum MediaType {
|
||||||
image
|
image
|
||||||
video
|
video
|
||||||
|
document
|
||||||
|
|
||||||
@@map("media_type")
|
@@map("media_type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { SendOtpDto } from './dto/send-otp.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 { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
@@ -37,6 +40,21 @@ export class AuthController {
|
|||||||
return this.authService.register(dto);
|
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')
|
@Post('login')
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
return this.authService.login(dto);
|
||||||
|
|||||||
+338
-22
@@ -24,6 +24,8 @@ import {
|
|||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.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 { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { parseUserProfile } from './profile.util';
|
import { parseUserProfile } from './profile.util';
|
||||||
@@ -33,6 +35,8 @@ import { SmsBillingService } from './sms-billing.service';
|
|||||||
const OTP_TTL_SECONDS = 300;
|
const OTP_TTL_SECONDS = 300;
|
||||||
/** Pending cross-site password change (longer than OTP so resend still works). */
|
/** Pending cross-site password change (longer than OTP so resend still works). */
|
||||||
const PENDING_PASSWORD_TTL_SECONDS = 600;
|
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_TTL_SECONDS = 60;
|
||||||
const HANDOFF_RATE_LIMIT = 10;
|
const HANDOFF_RATE_LIMIT = 10;
|
||||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
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) {
|
async login(dto: LoginDto) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { cellNumber: dto.cellNumber },
|
where: { cellNumber: dto.cellNumber },
|
||||||
@@ -430,26 +699,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
const businessNameFa =
|
await this.dispatchOtpSms(cellNumber, business);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -545,8 +795,14 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
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()) {
|
if (!this.sms.isEnabled()) {
|
||||||
return user;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedCode = await this.redis.getOtp(cellNumber);
|
const storedCode = await this.redis.getOtp(cellNumber);
|
||||||
@@ -555,7 +811,67 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.redis.deleteOtp(cellNumber);
|
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(
|
private async ensureCustomerMembershipForDomain(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
|
|||||||
|
|
||||||
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
|
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()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
private readonly maxFileSizeBytes: number;
|
private readonly maxFileSizeBytes: number;
|
||||||
@@ -375,6 +380,14 @@ export class MediaService {
|
|||||||
throw new BadRequestException('Uploaded file is empty');
|
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) {
|
if (file.size > this.maxFileSizeBytes) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File ${file.originalname} exceeds the maximum allowed size`,
|
`File ${file.originalname} exceeds the maximum allowed size`,
|
||||||
@@ -409,7 +422,7 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException(
|
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);
|
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) {
|
private contentTypeFromFormat(format?: string) {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
@@ -469,6 +530,8 @@ export class MediaService {
|
|||||||
return '.webp';
|
return '.webp';
|
||||||
case 'image/gif':
|
case 'image/gif':
|
||||||
return '.gif';
|
return '.gif';
|
||||||
|
case 'application/pdf':
|
||||||
|
return '.pdf';
|
||||||
default:
|
default:
|
||||||
return '.jpg';
|
return '.jpg';
|
||||||
}
|
}
|
||||||
@@ -493,6 +556,8 @@ export class MediaService {
|
|||||||
return '.mp4';
|
return '.mp4';
|
||||||
case 'video/webm':
|
case 'video/webm':
|
||||||
return '.webm';
|
return '.webm';
|
||||||
|
case 'application/pdf':
|
||||||
|
return '.pdf';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export class CreateProductDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
featuredMediaId?: string;
|
featuredMediaId?: string;
|
||||||
|
|
||||||
|
/** Optional PDF catalog file (media id; application/pdf, max 2MB). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogMediaId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@@ -165,6 +170,11 @@ export class UpdateProductDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
featuredMediaId?: string | null;
|
featuredMediaId?: string | null;
|
||||||
|
|
||||||
|
/** Optional PDF catalog file (media id). Pass null to clear. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogMediaId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ function slugify(value: string): string {
|
|||||||
type ProductWithRelations = Prisma.ProductGetPayload<{
|
type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
featuredMedia: true;
|
featuredMedia: true;
|
||||||
|
catalogMedia: true;
|
||||||
brand: { include: { imageMedia: true } };
|
brand: { include: { imageMedia: true } };
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const productListInclude = {
|
const productListInclude = {
|
||||||
featuredMedia: true,
|
featuredMedia: true,
|
||||||
|
catalogMedia: true,
|
||||||
brand: { include: { imageMedia: true } },
|
brand: { include: { imageMedia: true } },
|
||||||
} satisfies Prisma.ProductInclude;
|
} satisfies Prisma.ProductInclude;
|
||||||
|
|
||||||
@@ -105,10 +107,7 @@ export class ProductsService {
|
|||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -140,10 +139,7 @@ export class ProductsService {
|
|||||||
|
|
||||||
const product = await this.prisma.product.findFirst({
|
const product = await this.prisma.product.findFirst({
|
||||||
where: { id: productId, businessId },
|
where: { id: productId, businessId },
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
@@ -168,10 +164,7 @@ export class ProductsService {
|
|||||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -338,6 +331,13 @@ export class ProductsService {
|
|||||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
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(
|
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
||||||
businessId,
|
businessId,
|
||||||
dto.galleryMediaIds ?? [],
|
dto.galleryMediaIds ?? [],
|
||||||
@@ -363,6 +363,7 @@ export class ProductsService {
|
|||||||
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
||||||
status,
|
status,
|
||||||
featuredMediaId,
|
featuredMediaId,
|
||||||
|
catalogMediaId,
|
||||||
brandId,
|
brandId,
|
||||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
publishedAt: status === ContentStatus.published ? new Date() : null,
|
||||||
metadata: applySeoMetaToMetadata(
|
metadata: applySeoMetaToMetadata(
|
||||||
@@ -370,10 +371,7 @@ export class ProductsService {
|
|||||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
) as Prisma.InputJsonValue,
|
) as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.categoryId) {
|
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 existingContent = this.asRecord(existing.content);
|
||||||
const existingMetadata = this.asRecord(existing.metadata);
|
const existingMetadata = this.asRecord(existing.metadata);
|
||||||
|
|
||||||
@@ -496,16 +504,14 @@ export class ProductsService {
|
|||||||
: {}),
|
: {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
||||||
|
...(catalogMediaId !== undefined ? { catalogMediaId } : {}),
|
||||||
...(brandId !== undefined ? { brandId } : {}),
|
...(brandId !== undefined ? { brandId } : {}),
|
||||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||||
slug,
|
slug,
|
||||||
content: nextContent as Prisma.InputJsonValue,
|
content: nextContent as Prisma.InputJsonValue,
|
||||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.categoryId !== undefined) {
|
if (dto.categoryId !== undefined) {
|
||||||
@@ -563,6 +569,13 @@ export class ProductsService {
|
|||||||
) {
|
) {
|
||||||
dropped.push(existing.featuredMediaId);
|
dropped.push(existing.featuredMediaId);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
catalogMediaId !== undefined &&
|
||||||
|
existing.catalogMediaId != null &&
|
||||||
|
existing.catalogMediaId !== catalogMediaId
|
||||||
|
) {
|
||||||
|
dropped.push(existing.catalogMediaId);
|
||||||
|
}
|
||||||
await this.mediaService.deleteUnused(businessId, dropped);
|
await this.mediaService.deleteUnused(businessId, dropped);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -590,6 +603,9 @@ export class ProductsService {
|
|||||||
productId,
|
productId,
|
||||||
existing.featuredMediaId,
|
existing.featuredMediaId,
|
||||||
);
|
);
|
||||||
|
if (existing.catalogMediaId != null) {
|
||||||
|
mediaIds.push(existing.catalogMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.mediaAttachment.deleteMany({
|
this.prisma.mediaAttachment.deleteMany({
|
||||||
@@ -718,6 +734,9 @@ export class ProductsService {
|
|||||||
...readSeoMetaFromMetadata(metadata),
|
...readSeoMetaFromMetadata(metadata),
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||||
|
catalogMediaId: product.catalogMediaId?.toString() ?? null,
|
||||||
|
catalogUrl: product.catalogMedia?.publicUrl ?? null,
|
||||||
|
catalogFileName: product.catalogMedia?.originalFileName ?? null,
|
||||||
image,
|
image,
|
||||||
thumbnail: thumbnailUrl ?? image,
|
thumbnail: thumbnailUrl ?? image,
|
||||||
images: galleryAttachments.map((item) => ({
|
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(
|
private async assertCategoryBelongsToBusiness(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
categoryId: bigint,
|
categoryId: bigint,
|
||||||
|
|||||||
@@ -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(
|
async setHandoffTicket(
|
||||||
ticket: string,
|
ticket: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user