Files
backend/src/user-products/user-products.service.ts
T
Alireza HassaniandCursor 6517877bed Use id+slug paths for user products and count their visits.
Align storefront/sitemap URLs with catalog products, add by-id public APIs, and include user-product detail views in the product visit chart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:35:46 +03:30

1747 lines
46 KiB
TypeScript

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<string>();
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<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
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<Prisma.UserProductWhereInput> {
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');
}
}
}