diff --git a/database/migrations/099_product_catalog_pdf.sql b/database/migrations/099_product_catalog_pdf.sql new file mode 100644 index 0000000..4f773e0 --- /dev/null +++ b/database/migrations/099_product_catalog_pdf.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7592a4d..0aecff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -393,6 +393,7 @@ model Media { instructions instructions[] workshops workshops[] featuredProducts Product[] @relation("ProductFeaturedMedia") + catalogProducts Product[] @relation("ProductCatalogMedia") featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") website_image_slot_items website_image_slot_items[] website_slider_slides website_slider_slides[] @@ -524,6 +525,7 @@ model Product { stockQuantity Int? @map("stock_quantity") status ContentStatus @default(draft) featuredMediaId BigInt? @map("featured_media_id") + catalogMediaId BigInt? @map("catalog_media_id") sortOrder Int @default(0) @map("sort_order") publishedAt DateTime? @map("published_at") @db.Timestamptz(6) metadata Json @default("{}") @@ -541,6 +543,7 @@ model Product { brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction) + catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction) shoppingCardItems ShoppingCardItem[] storeItem StoreItem[] @@ -549,6 +552,7 @@ model Product { @@index([brandId], map: "idx_products_brand_id") @@index([businessId, status], map: "idx_products_business_status") @@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published") + @@index([catalogMediaId], map: "idx_products_catalog_media_id") @@map("products") } @@ -1683,6 +1687,7 @@ model UserProduct { enum MediaType { image video + document @@map("media_type") } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 15996ae..9f695d6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c2f7f6d..dfa6305 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + const registrationToken = randomBytes(32).toString('hex'); + await this.redis.setSignupVerifiedToken( + registrationToken, + payload, + PENDING_SIGNUP_TTL_SECONDS, + ); + return registrationToken; } private async ensureCustomerMembershipForDomain( diff --git a/src/auth/dto/signup-complete.dto.ts b/src/auth/dto/signup-complete.dto.ts new file mode 100644 index 0000000..d70051e --- /dev/null +++ b/src/auth/dto/signup-complete.dto.ts @@ -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; +} diff --git a/src/auth/dto/signup-start.dto.ts b/src/auth/dto/signup-start.dto.ts new file mode 100644 index 0000000..3646611 --- /dev/null +++ b/src/auth/dto/signup-start.dto.ts @@ -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; +} diff --git a/src/auth/dto/signup-verify-otp.dto.ts b/src/auth/dto/signup-verify-otp.dto.ts new file mode 100644 index 0000000..8354fe5 --- /dev/null +++ b/src/auth/dto/signup-verify-otp.dto.ts @@ -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; +} diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 6552b00..cccf5b6 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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 ''; } diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts index 07c7735..5523d53 100644 --- a/src/products/dto/product.dto.ts +++ b/src/products/dto/product.dto.ts @@ -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 }) diff --git a/src/products/products.service.ts b/src/products/products.service.ts index cfc7ff2..cd92b5d 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -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, diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index f9fd975..5039e8b 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -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 { + 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 { + 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 { + 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,