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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 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 '';
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user