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
@@ -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);
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+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 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 '';
} }
+10
View File
@@ -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 })
+57 -20
View File
@@ -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,
+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( async setHandoffTicket(
ticket: string, ticket: string,
userId: string, userId: string,