import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { CityLevel, ContentStatus, MediaEntityType, Prisma, TechnicalFieldType, } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; import { applySeoMetaToMetadata, normalizeSeoMetaInput, readSeoMetaFromMetadata, } from '../common/seo-meta.util'; import { MediaService } from '../media/media.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserProductDto, ListAdminUserProductsDto, ListMyUserProductsDto, ListPublicUserProductsDto, UpdateUserProductDto, UpdateUserProductStatusDto, UserProductTechnicalValueDto, } from './dto/user-product.dto'; import { TenantService } from '../tenant/tenant.service'; import { SitemapService } from '../sitemap/sitemap.service'; import { slugifyForUrl } from '../sitemap/seo-slug.util'; function buildUserProductSlug(titleFa: string, titleEn?: string | null): string { return slugifyForUrl( titleFa.trim() || titleEn?.trim() || '', 'user-product', ); } type FormField = { id: string; label: string; key: string; type: TechnicalFieldType; isRequired: boolean; sortOrder: number; options: { id: string; label: string; value: string; sortOrder: number }[]; }; const userProductListInclude = { featuredMedia: true, city: true, country: true, } satisfies Prisma.UserProductInclude; type UserProductListRow = Prisma.UserProductGetPayload<{ include: typeof userProductListInclude; }>; const userProductAdminListInclude = { ...userProductListInclude, user: { select: { id: true, firstName: true, lastName: true, firstNameEn: true, lastNameEn: true, cellNumber: true, }, }, } satisfies Prisma.UserProductInclude; type UserProductAdminListRow = Prisma.UserProductGetPayload<{ include: typeof userProductAdminListInclude; }>; const userProductDetailInclude = { ...userProductListInclude, technicalFieldValues: { include: { selectedOptions: true, }, }, } satisfies Prisma.UserProductInclude; type UserProductDetailRow = Prisma.UserProductGetPayload<{ include: typeof userProductDetailInclude; }>; const userProductAdminDetailInclude = { ...userProductAdminListInclude, technicalFieldValues: { include: { selectedOptions: true, }, }, } satisfies Prisma.UserProductInclude; type UserProductAdminDetailRow = Prisma.UserProductGetPayload<{ include: typeof userProductAdminDetailInclude; }>; @Injectable() export class UserProductsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly technicalFormService: CategoryTechnicalFormService, private readonly mediaService: MediaService, private readonly tenant: TenantService, private readonly sitemap: SitemapService, ) {} async listPublic(host: string, query: ListPublicUserProductsDto) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const page = query.page ?? 1; const pageSize = Math.min(Math.max(query.pageSize ?? 12, 1), 100); const skip = (page - 1) * pageSize; const where = await this.buildPublicWhere(businessId, query); const [items, total] = await Promise.all([ this.prisma.userProduct.findMany({ where, orderBy: [ { publishedAt: 'desc' }, { createdAt: 'desc' }, ], skip, take: pageSize, include: userProductListInclude, }), this.prisma.userProduct.count({ where }), ]); const categoryByEntity = await this.loadCategoriesForProducts( businessId, items.map((item) => item.id), ); const serialized = items.map((item) => this.serializeListItem(item, categoryByEntity.get(item.id.toString())), ); const promotedOnly = query.promoted === true; const ordered = promotedOnly ? serialized : [ ...serialized.filter((item) => item.promoted), ...serialized.filter((item) => !item.promoted), ]; return { items: ordered, total, page, pageSize, }; } async getPublicBySlug(host: string, slug: string) { const business = await this.tenant.resolveBusinessByDomain(host); const product = await this.prisma.userProduct.findFirst({ where: { businessId: business.id, slug, status: ContentStatus.published, }, include: userProductDetailInclude, }); if (!product) { throw new NotFoundException('User product not found'); } return this.serializePublicDetail(business.id, product); } async getPublicById(host: string, productIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); let productId: bigint; try { productId = BigInt(productIdRaw); } catch { throw new NotFoundException('User product not found'); } const product = await this.prisma.userProduct.findFirst({ where: { businessId: business.id, id: productId, status: ContentStatus.published, }, include: userProductDetailInclude, }); if (!product) { throw new NotFoundException('User product not found'); } return this.serializePublicDetail(business.id, product); } async getPublicTechnicalInfoBySlug(host: string, slug: string) { const business = await this.tenant.resolveBusinessByDomain(host); const product = await this.prisma.userProduct.findFirst({ where: { businessId: business.id, slug, status: ContentStatus.published, }, select: { id: true }, }); if (!product) { throw new NotFoundException('User product not found'); } return this.getPublicTechnicalInfoForProduct(business.id, product.id); } async getPublicTechnicalInfoById(host: string, productIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); let productId: bigint; try { productId = BigInt(productIdRaw); } catch { throw new NotFoundException('User product not found'); } const product = await this.prisma.userProduct.findFirst({ where: { businessId: business.id, id: productId, status: ContentStatus.published, }, select: { id: true }, }); if (!product) { throw new NotFoundException('User product not found'); } return this.getPublicTechnicalInfoForProduct(business.id, product.id); } private async serializePublicDetail( businessId: bigint, product: UserProductDetailRow, ) { const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ product.id, ]); const gallery = await this.loadGalleryAttachments(businessId, product.id); return { product: this.serializeDetail( product, categoryByEntity.get(product.id.toString()), gallery, ), }; } private async getPublicTechnicalInfoForProduct( businessId: bigint, productId: bigint, ) { const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ productId, ]); const category = categoryByEntity.get(productId.toString()); if (!category) { return { form: null, values: [], message: 'User product has no category assigned', }; } const form = await this.technicalFormService.getFormForCategory( businessId, category.id, ); if (!form) { return { form: null, values: [] }; } const detail = await this.prisma.userProduct.findFirst({ where: { id: productId }, include: userProductDetailInclude, }); if (!detail) { throw new NotFoundException('User product not found'); } const serialized = this.serializeDetail(detail, category, []); return { form, values: serialized.technicalValues, }; } async list( businessIdRaw: string, query: ListMyUserProductsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); const page = query.page ?? 1; const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); const skip = (page - 1) * pageSize; const where: Prisma.UserProductWhereInput = { businessId, userId: actor.id, }; const [items, total] = await Promise.all([ this.prisma.userProduct.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: pageSize, include: userProductListInclude, }), this.prisma.userProduct.count({ where }), ]); const categoryByEntity = await this.loadCategoriesForProducts( businessId, items.map((item) => item.id), ); return { items: items.map((item) => this.serializeListItem(item, categoryByEntity.get(item.id.toString())), ), total, page, pageSize, }; } async create( businessIdRaw: string, dto: CreateUserProductDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); return this.createForUser(businessId, dto, actor.id); } private async createForUser( businessId: bigint, dto: CreateUserProductDto, userId: bigint, ) { const titleFa = dto.titleFa.trim(); if (!titleFa) { throw new BadRequestException('titleFa is required'); } const categoryId = BigInt(dto.categoryId); await this.assertProductCategory(businessId, categoryId); const countryId = BigInt(dto.countryId); const cityId = BigInt(dto.cityId); await this.assertCountryAndCity(countryId, cityId); let featuredMediaId: bigint | null = null; if (dto.featuredMediaId) { featuredMediaId = BigInt(dto.featuredMediaId); await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); } const galleryMediaIds = await this.resolveGalleryMediaIds( businessId, dto.galleryMediaIds ?? [], ); const form = await this.technicalFormService.getFormForCategory( businessId, categoryId, ); const technicalValues = dto.technicalValues ?? []; this.validateTechnicalValues(form?.fields ?? [], technicalValues); const slug = await this.ensureUniqueSlug( businessId, buildUserProductSlug(titleFa, dto.titleEn), ); const priceCurrency = dto.priceCurrency ?? 'IRT'; const content: Prisma.InputJsonValue = { ...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}), }; const metadata = applySeoMetaToMetadata( { priceByExpert: dto.priceByExpert === true, }, normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), ) as Prisma.InputJsonValue; const created = await this.prisma.$transaction(async (tx) => { const product = await tx.userProduct.create({ data: { businessId, userId, title: titleFa, slug, description: dto.description?.trim() || null, content, price: dto.price === undefined || dto.price === null ? null : dto.price, priceCurrency, status: ContentStatus.draft, featuredMediaId, countryId, cityId, deliveryNote: dto.deliveryNote?.trim() || null, condition: dto.condition, technicalNotes: dto.technicalNotes?.trim() || null, metadata, }, include: userProductListInclude, }); await tx.categoryAssignment.create({ data: { businessId, categoryId, entityType: MediaEntityType.user_product, entityId: product.id, }, }); await this.syncGalleryAttachments( tx, businessId, product.id, galleryMediaIds, ); if (form?.fields.length) { await this.saveTechnicalValues( tx, businessId, product.id, form.fields, technicalValues, ); } return product; }); const category = await this.prisma.category.findFirst({ where: { id: categoryId, businessId }, select: { id: true, name: true, nameFa: true }, }); const withUser = await this.prisma.userProduct.findFirst({ where: { id: created.id }, include: userProductAdminListInclude, }); await this.sitemap.invalidateForBusiness(businessId); return { message: 'User product created successfully', product: withUser ? this.serializeAdminListItem(withUser, category ?? undefined) : this.serializeListItem(created, category ?? undefined), }; } async getOne( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertCustomerAccess(businessId, actor); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId, userId: actor.id }, include: userProductDetailInclude, }); if (!product) { throw new NotFoundException('User product not found'); } const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ product.id, ]); const gallery = await this.loadGalleryAttachments(businessId, product.id); return { product: this.serializeDetail( product, categoryByEntity.get(product.id.toString()), gallery, ), }; } async update( businessIdRaw: string, productIdRaw: string, dto: UpdateUserProductDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertCustomerAccess(businessId, actor); const existing = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId, userId: actor.id }, select: { id: true, title: true, slug: true, metadata: true, featuredMediaId: true, }, }); if (!existing) { throw new NotFoundException('User product not found'); } return this.updateExisting(businessId, productId, dto, existing); } private async updateExisting( businessId: bigint, productId: bigint, dto: UpdateUserProductDto, existing: { id: bigint; title: string; slug: string; metadata: unknown; featuredMediaId: bigint | null; }, ) { const titleFa = dto.titleFa.trim(); if (!titleFa) { throw new BadRequestException('titleFa is required'); } const categoryId = BigInt(dto.categoryId); await this.assertProductCategory(businessId, categoryId); const countryId = BigInt(dto.countryId); const cityId = BigInt(dto.cityId); await this.assertCountryAndCity(countryId, cityId); let featuredMediaId: bigint | null = null; if (dto.featuredMediaId) { featuredMediaId = BigInt(dto.featuredMediaId); await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); } const galleryMediaIds = dto.galleryMediaIds !== undefined ? await this.resolveGalleryMediaIds(businessId, dto.galleryMediaIds) : null; const form = await this.technicalFormService.getFormForCategory( businessId, categoryId, ); const technicalValues = dto.technicalValues ?? []; this.validateTechnicalValues(form?.fields ?? [], technicalValues); const existingMetadata = this.asRecord(existing.metadata); const content: Prisma.InputJsonValue = { ...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}), }; const metadata = applySeoMetaToMetadata( { ...existingMetadata, priceByExpert: dto.priceByExpert === true, }, normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), ) as Prisma.InputJsonValue; let slug = existing.slug; if (titleFa !== existing.title) { slug = await this.ensureUniqueSlug( businessId, buildUserProductSlug(titleFa, dto.titleEn), productId, ); } const previousGalleryIds = await this.mediaService.galleryMediaIds( businessId, MediaEntityType.user_product, productId, ); const updated = await this.prisma.$transaction(async (tx) => { const product = await tx.userProduct.update({ where: { id: productId }, data: { title: titleFa, slug, description: dto.description?.trim() || null, content, price: dto.price === undefined || dto.price === null ? null : dto.price, priceCurrency: dto.priceCurrency ?? 'IRT', featuredMediaId, countryId, cityId, deliveryNote: dto.deliveryNote?.trim() || null, condition: dto.condition, technicalNotes: dto.technicalNotes?.trim() || null, metadata, }, include: userProductAdminListInclude, }); await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, }, }); await tx.categoryAssignment.create({ data: { businessId, categoryId, entityType: MediaEntityType.user_product, entityId: productId, }, }); if (galleryMediaIds !== null) { await this.syncGalleryAttachments( tx, businessId, productId, galleryMediaIds, ); } await tx.userProductTechnicalFieldValue.deleteMany({ where: { userProductId: productId }, }); if (form?.fields.length) { await this.saveTechnicalValues( tx, businessId, productId, form.fields, technicalValues, ); } return product; }); const category = await this.prisma.category.findFirst({ where: { id: categoryId, businessId }, select: { id: true, name: true, nameFa: true }, }); const dropped: bigint[] = []; if (galleryMediaIds !== null) { dropped.push( ...this.mediaService.idsNotIn(previousGalleryIds, galleryMediaIds), ); } if ( existing.featuredMediaId != null && existing.featuredMediaId !== featuredMediaId ) { dropped.push(existing.featuredMediaId); } await this.mediaService.deleteUnused(businessId, dropped); await this.sitemap.invalidateForBusiness(businessId); return { message: 'User product updated successfully', product: this.serializeAdminListItem(updated, category ?? undefined), }; } async uploadMedia( businessIdRaw: string, files: Express.Multer.File[], actor: AuthUser, ) { return this.mediaService.uploadManyForCustomer(businessIdRaw, files, actor); } async remove( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertCustomerAccess(businessId, actor); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId, userId: actor.id }, select: { id: true, featuredMediaId: true }, }); if (!product) { throw new NotFoundException('User product not found'); } const mediaIds = await this.mediaService.allLinkedMediaIds( businessId, MediaEntityType.user_product, productId, product.featuredMediaId, ); await this.prisma.$transaction(async (tx) => { await tx.mediaAttachment.deleteMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, }, }); await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, }, }); await tx.userProduct.delete({ where: { id: productId } }); }); await this.mediaService.deleteUnused(businessId, mediaIds); await this.sitemap.invalidateForBusiness(businessId); return { message: 'User product deleted successfully' }; } async promote( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertCustomerAccess(businessId, actor); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId, userId: actor.id }, include: userProductListInclude, }); if (!product) { throw new NotFoundException('User product not found'); } const metadata = this.asRecord(product.metadata); const nextMetadata: Prisma.InputJsonValue = { ...metadata, promoted: true, promotedAt: new Date().toISOString(), }; const updated = await this.prisma.userProduct.update({ where: { id: productId }, data: { metadata: nextMetadata }, include: userProductListInclude, }); const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ updated.id, ]); return { message: 'User product promoted successfully', product: this.serializeListItem( updated, categoryByEntity.get(updated.id.toString()), ), }; } async adminList( businessIdRaw: string, query: ListAdminUserProductsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.read'); const page = query.page ?? 1; const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); const skip = (page - 1) * pageSize; const where: Prisma.UserProductWhereInput = { businessId, ...(query.status ? { status: query.status } : {}), }; const [items, total] = await Promise.all([ this.prisma.userProduct.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: pageSize, include: userProductAdminListInclude, }), this.prisma.userProduct.count({ where }), ]); const categoryByEntity = await this.loadCategoriesForProducts( businessId, items.map((item) => item.id), ); return { items: items.map((item) => this.serializeAdminListItem( item, categoryByEntity.get(item.id.toString()), ), ), total, page, pageSize, }; } async adminGetOne( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.read'); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId }, include: userProductAdminDetailInclude, }); if (!product) { throw new NotFoundException('User product not found'); } const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ product.id, ]); const gallery = await this.loadGalleryAttachments(businessId, product.id); return { product: this.serializeAdminDetail( product, categoryByEntity.get(product.id.toString()), gallery, ), }; } async adminCreate( businessIdRaw: string, dto: CreateUserProductDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.create'); return this.createForUser(businessId, dto, actor.id); } async adminUpdate( businessIdRaw: string, productIdRaw: string, dto: UpdateUserProductDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.update'); const existing = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId }, select: { id: true, title: true, slug: true, metadata: true, userId: true, featuredMediaId: true, }, }); if (!existing) { throw new NotFoundException('User product not found'); } return this.updateExisting(businessId, productId, dto, existing); } async adminUpdateStatus( businessIdRaw: string, productIdRaw: string, dto: UpdateUserProductStatusDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.update'); const existing = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId }, select: { id: true, status: true, publishedAt: true }, }); if (!existing) { throw new NotFoundException('User product not found'); } const status = dto.status; const publishedAt = status === ContentStatus.published ? existing.publishedAt ?? new Date() : null; const updated = await this.prisma.userProduct.update({ where: { id: productId }, data: { status, publishedAt }, include: userProductAdminListInclude, }); const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ updated.id, ]); await this.sitemap.invalidateForBusiness(businessId); return { message: 'User product status updated successfully', product: this.serializeAdminListItem( updated, categoryByEntity.get(updated.id.toString()), ), }; } async adminPromote( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.update'); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId }, include: userProductAdminListInclude, }); if (!product) { throw new NotFoundException('User product not found'); } const metadata = this.asRecord(product.metadata); const nextMetadata: Prisma.InputJsonValue = { ...metadata, promoted: true, promotedAt: new Date().toISOString(), }; const updated = await this.prisma.userProduct.update({ where: { id: productId }, data: { metadata: nextMetadata }, include: userProductAdminListInclude, }); const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ updated.id, ]); return { message: 'User product promoted successfully', product: this.serializeAdminListItem( updated, categoryByEntity.get(updated.id.toString()), ), }; } async adminRemove( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.delete'); const product = await this.prisma.userProduct.findFirst({ where: { id: productId, businessId }, select: { id: true }, }); if (!product) { throw new NotFoundException('User product not found'); } await this.prisma.$transaction(async (tx) => { await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, }, }); await tx.userProduct.delete({ where: { id: productId } }); }); await this.sitemap.invalidateForBusiness(businessId); return { message: 'User product deleted successfully' }; } async listCategoriesForAdmin(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.read'); return this.listProductCategories(businessId); } async getCategoryTechnicalFormForAdmin( businessIdRaw: string, categoryIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const categoryId = BigInt(categoryIdRaw); await this.assertAdminAccess(businessId, actor, 'user_products.read'); await this.assertProductCategory(businessId, categoryId); const form = await this.technicalFormService.getFormForCategory( businessId, categoryId, ); return { form }; } async listCategories(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); return this.listProductCategories(businessId); } async getCategoryTechnicalForm( businessIdRaw: string, categoryIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const categoryId = BigInt(categoryIdRaw); await this.assertCustomerAccess(businessId, actor); await this.assertProductCategory(businessId, categoryId); const form = await this.technicalFormService.getFormForCategory( businessId, categoryId, ); return { form }; } private validateTechnicalValues( fields: FormField[], values: UserProductTechnicalValueDto[], ) { if (!fields.length) { if (values.length) { throw new BadRequestException( 'This category has no technical form fields', ); } return; } const fieldMap = new Map(fields.map((field) => [field.id, field])); const submittedIds = new Set(); for (const entry of values) { if (submittedIds.has(entry.fieldId)) { throw new BadRequestException( `Duplicate technical value for field "${entry.fieldId}"`, ); } submittedIds.add(entry.fieldId); const field = fieldMap.get(entry.fieldId); if (!field) { throw new BadRequestException( `Unknown technical field "${entry.fieldId}"`, ); } this.validateFieldEntry(field, entry); } for (const field of fields) { if (!field.isRequired) { continue; } const entry = values.find((item) => item.fieldId === field.id); if (!entry || !this.hasFieldValue(field, entry)) { throw new BadRequestException( `Required field "${field.label}" is missing`, ); } } } private validateFieldEntry( field: FormField, entry: UserProductTechnicalValueDto, ) { if (field.type === 'text' || field.type === 'textarea') { if (entry.optionId || entry.optionIds?.length) { throw new BadRequestException( `Field "${field.label}" expects a text value`, ); } const text = entry.textValue?.trim() ?? ''; if (field.isRequired && !text) { throw new BadRequestException( `Required field "${field.label}" cannot be empty`, ); } return; } if (field.type === 'select') { if (entry.textValue || entry.optionIds?.length) { throw new BadRequestException( `Field "${field.label}" expects a single optionId`, ); } if (!entry.optionId) { if (field.isRequired) { throw new BadRequestException( `Required field "${field.label}" cannot be empty`, ); } return; } this.findOptionById(field, entry.optionId); return; } if (entry.textValue || entry.optionId) { throw new BadRequestException( `Field "${field.label}" expects optionIds`, ); } const optionIds = entry.optionIds ?? []; if (!optionIds.length) { if (field.isRequired) { throw new BadRequestException( `Required field "${field.label}" cannot be empty`, ); } return; } for (const optionId of optionIds) { this.findOptionById(field, optionId); } } private hasFieldValue( field: FormField, entry: UserProductTechnicalValueDto, ): boolean { if (field.type === 'text' || field.type === 'textarea') { return Boolean(entry.textValue?.trim()); } if (field.type === 'select') { return Boolean(entry.optionId); } return Boolean(entry.optionIds?.length); } private findOptionById(field: FormField, optionId: string) { const option = field.options.find((item) => item.id === optionId); if (!option) { throw new BadRequestException( `Invalid option "${optionId}" for field "${field.label}"`, ); } return option; } private async saveTechnicalValues( tx: Prisma.TransactionClient, businessId: bigint, userProductId: bigint, fields: FormField[], values: UserProductTechnicalValueDto[], ) { const fieldMap = new Map(fields.map((field) => [field.id, field])); for (const entry of values) { const field = fieldMap.get(entry.fieldId); if (!field) { continue; } if (!this.hasFieldValue(field, entry)) { continue; } const fieldId = BigInt(field.id); if (field.type === 'text' || field.type === 'textarea') { await tx.userProductTechnicalFieldValue.create({ data: { businessId, userProductId, fieldId, textValue: entry.textValue!.trim(), }, }); continue; } if (field.type === 'select') { await tx.userProductTechnicalFieldValue.create({ data: { businessId, userProductId, fieldId, optionId: BigInt(entry.optionId!), }, }); continue; } const fieldValue = await tx.userProductTechnicalFieldValue.create({ data: { businessId, userProductId, fieldId, }, }); for (const optionId of entry.optionIds ?? []) { await tx.userProductTechnicalFieldValueOption.create({ data: { fieldValueId: fieldValue.id, optionId: BigInt(optionId), }, }); } } } private async loadCategoriesForProducts( businessId: bigint, productIds: bigint[], ) { const map = new Map< string, { id: bigint; name: string; nameFa: string | null } >(); if (!productIds.length) { return map; } const assignments = await this.prisma.categoryAssignment.findMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: { in: productIds }, }, include: { category: { select: { id: true, name: true, nameFa: true }, }, }, }); for (const assignment of assignments) { map.set(assignment.entityId.toString(), assignment.category); } return map; } private serializeListItem( product: UserProductListRow, category?: { id: bigint; name: string; nameFa: string | null } | null, ) { const content = this.asRecord(product.content); const metadata = this.asRecord(product.metadata); const titleEn = typeof content.titleEn === 'string' ? content.titleEn : null; const priceByExpert = metadata.priceByExpert === true; const promoted = metadata.promoted === true; return { id: product.id.toString(), slug: product.slug, pathSlug: buildUserProductSlug(product.title, titleEn), title: product.title, titleFa: product.title, titleEn, description: product.description, price: product.price === null ? null : Number(product.price), priceCurrency: product.priceCurrency, priceByExpert, promoted, ...readSeoMetaFromMetadata(metadata), status: product.status, condition: product.condition, deliveryNote: product.deliveryNote, technicalNotes: product.technicalNotes, cityId: product.cityId.toString(), countryId: product.countryId.toString(), cityName: product.city.nameEn, cityNameFa: product.city.nameFa, countryName: product.country.nameEn, countryNameFa: product.country.nameFa, imageUrl: product.featuredMedia?.publicUrl ?? null, categoryId: category?.id.toString() ?? null, categoryName: category?.name ?? null, categoryNameFa: category?.nameFa ?? null, createdAt: product.createdAt, publishedAt: product.publishedAt, }; } private serializeDetail( product: UserProductDetailRow, category?: { id: bigint; name: string; nameFa: string | null } | null, gallery: { mediaId: bigint; publicUrl: string }[] = [], ) { const base = this.serializeListItem( product as UserProductListRow, category, ); const technicalValues: UserProductTechnicalValueDto[] = []; for (const value of product.technicalFieldValues) { const fieldId = value.fieldId.toString(); if (value.textValue != null) { technicalValues.push({ fieldId, textValue: value.textValue }); continue; } if (value.optionId != null) { technicalValues.push({ fieldId, optionId: value.optionId.toString(), }); continue; } if (value.selectedOptions.length) { technicalValues.push({ fieldId, optionIds: value.selectedOptions.map((item) => item.optionId.toString(), ), }); } } return { ...base, countryId: product.countryId.toString(), cityId: product.cityId.toString(), countrySlug: product.country.slug, featuredMediaId: product.featuredMediaId?.toString() ?? null, galleryMediaIds: gallery.map((item) => item.mediaId.toString()), images: gallery.map((item) => ({ mediaId: item.mediaId.toString(), url: item.publicUrl, })), technicalValues, }; } private serializeAdminListItem( product: UserProductAdminListRow, category?: { id: bigint; name: string; nameFa: string | null } | null, ) { const base = this.serializeListItem( product as UserProductListRow, category, ); const owner = this.formatOwnerNames(product.user); return { ...base, ownerId: product.user.id.toString(), ownerName: owner.nameEn, ownerNameFa: owner.nameFa, ownerCell: product.user.cellNumber, }; } private serializeAdminDetail( product: UserProductAdminDetailRow, category?: { id: bigint; name: string; nameFa: string | null } | null, gallery: { mediaId: bigint; publicUrl: string }[] = [], ) { const detail = this.serializeDetail( product as UserProductDetailRow, category, gallery, ); const owner = this.formatOwnerNames(product.user); return { ...detail, ownerId: product.user.id.toString(), ownerName: owner.nameEn, ownerNameFa: owner.nameFa, ownerCell: product.user.cellNumber, }; } private formatOwnerNames(user: { firstName: string | null; lastName: string | null; firstNameEn: string | null; lastNameEn: string | null; cellNumber: string; }) { const nameFa = [user.firstName, user.lastName] .filter(Boolean) .join(' ') .trim(); const nameEn = [user.firstNameEn, user.lastNameEn] .filter(Boolean) .join(' ') .trim(); const fallback = nameFa || nameEn || user.cellNumber; return { nameFa: nameFa || fallback, nameEn: nameEn || fallback, }; } private async listProductCategories(businessId: bigint) { const items = await this.prisma.category.findMany({ where: { businessId, entityType: MediaEntityType.product, isActive: true, }, orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], select: { id: true, name: true, nameFa: true, parentId: true, }, }); return { items: items.map((item) => ({ id: item.id.toString(), name: item.name, nameFa: item.nameFa, parentId: item.parentId?.toString() ?? null, })), }; } private asRecord(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } private async ensureUniqueSlug( businessId: bigint, baseSlug: string, excludeId?: bigint, ) { let slug = baseSlug; let suffix = 1; while (true) { const existing = await this.prisma.userProduct.findFirst({ where: { businessId, slug, ...(excludeId ? { id: { not: excludeId } } : {}), }, select: { id: true }, }); if (!existing) { return slug; } suffix += 1; slug = `${baseSlug}-${suffix}`; } } private async assertProductCategory(businessId: bigint, categoryId: bigint) { const category = await this.prisma.category.findFirst({ where: { id: categoryId, businessId, entityType: MediaEntityType.product, isActive: true, }, }); if (!category) { throw new NotFoundException('Product category not found'); } return category; } private async assertCountryAndCity(countryId: bigint, cityId: bigint) { const [country, city] = await Promise.all([ this.prisma.city.findFirst({ where: { id: countryId, level: CityLevel.country, isActive: true }, }), this.prisma.city.findFirst({ where: { id: cityId, level: CityLevel.city, isActive: true }, }), ]); if (!country) { throw new BadRequestException('Country not found'); } if (!city) { throw new BadRequestException('City not found'); } let cursor: { id: bigint; parentId: bigint | null; level: CityLevel } | null = city; let belongsToCountry = false; while (cursor) { if (cursor.id === countryId) { belongsToCountry = true; break; } if (!cursor.parentId) { break; } cursor = await this.prisma.city.findFirst({ where: { id: cursor.parentId }, select: { id: true, parentId: true, level: true }, }); } if (!belongsToCountry) { throw new BadRequestException('City does not belong to the selected country'); } } private async buildPublicWhere( businessId: bigint, query: ListPublicUserProductsDto, ): Promise { let entityIds: bigint[] | undefined; if (query.categoryId) { const assignments = await this.prisma.categoryAssignment.findMany({ where: { businessId, categoryId: BigInt(query.categoryId), entityType: MediaEntityType.user_product, }, select: { entityId: true }, }); entityIds = assignments.map((item) => item.entityId); if (entityIds.length === 0) { return { id: { in: [] } }; } } const search = (query.q ?? query.name)?.trim(); const and: Prisma.UserProductWhereInput[] = []; if (search) { and.push({ OR: [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, ], }); } if (query.promoted === true) { and.push({ metadata: { path: ['promoted'], equals: true, }, }); } return { businessId, status: ContentStatus.published, ...(entityIds ? { id: { in: entityIds } } : {}), ...(query.cityId ? { cityId: BigInt(query.cityId) } : {}), ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}), ...(query.condition ? { condition: query.condition } : {}), ...(and.length ? { AND: and } : {}), }; } private async assertMediaBelongsToBusiness( businessId: bigint, mediaId: bigint, ) { const media = await this.prisma.media.findFirst({ where: { id: mediaId, businessId }, }); if (!media) { throw new BadRequestException('Media not found for this business'); } } private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { const ids = rawIds.map((id) => BigInt(id)); for (const mediaId of ids) { await this.assertMediaBelongsToBusiness(businessId, mediaId); } return ids; } private async syncGalleryAttachments( tx: Prisma.TransactionClient, businessId: bigint, productId: bigint, mediaIds: bigint[], ) { await tx.mediaAttachment.deleteMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, isFeatured: false, }, }); for (const [index, mediaId] of mediaIds.entries()) { await tx.mediaAttachment.create({ data: { businessId, mediaId, entityType: MediaEntityType.user_product, entityId: productId, sortOrder: index, isFeatured: false, }, }); } } private async loadGalleryAttachments(businessId: bigint, productId: bigint) { const attachments = await this.prisma.mediaAttachment.findMany({ where: { businessId, entityType: MediaEntityType.user_product, entityId: productId, isFeatured: false, }, include: { media: true }, orderBy: { sortOrder: 'asc' }, }); return attachments.map((item) => ({ mediaId: item.mediaId, publicUrl: item.media.publicUrl, })); } private async assertAdminAccess( businessId: bigint, actor: AuthUser, permission: string, ) { if (await this.permissions.isSuperAdmin(actor.id)) { return; } const allowed = await this.permissions.hasBusinessPermission( actor.id, businessId, permission, ); if (!allowed) { throw new ForbiddenException('Insufficient permissions'); } } private async assertCustomerAccess(businessId: bigint, actor: AuthUser) { if (await this.permissions.isSuperAdmin(actor.id)) { return; } const membership = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId: actor.id }, }, }); if (!membership) { throw new ForbiddenException('You are not a customer of this business'); } } }