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>
This commit is contained in:
Alireza Hassani
2026-09-15 15:35:46 +03:30
co-authored by Cursor
parent 3aef62c1b8
commit 6517877bed
19 changed files with 392 additions and 86 deletions
+94 -32
View File
@@ -32,14 +32,12 @@ import {
} 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 slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'user-product'
function buildUserProductSlug(titleFa: string, titleEn?: string | null): string {
return slugifyForUrl(
titleFa.trim() || titleEn?.trim() || '',
'user-product',
);
}
@@ -168,11 +166,9 @@ export class UserProductsService {
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const product = await this.prisma.userProduct.findFirst({
where: {
businessId,
businessId: business.id,
slug,
status: ContentStatus.published,
},
@@ -183,6 +179,81 @@ export class UserProductsService {
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,
]);
@@ -197,27 +268,14 @@ export class UserProductsService {
};
}
async getPublicTechnicalInfoBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const product = await this.prisma.userProduct.findFirst({
where: {
businessId,
slug,
status: ContentStatus.published,
},
select: { id: true },
});
if (!product) {
throw new NotFoundException('User product not found');
}
private async getPublicTechnicalInfoForProduct(
businessId: bigint,
productId: bigint,
) {
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
product.id,
productId,
]);
const category = categoryByEntity.get(product.id.toString());
const category = categoryByEntity.get(productId.toString());
if (!category) {
return {
form: null,
@@ -235,7 +293,7 @@ export class UserProductsService {
}
const detail = await this.prisma.userProduct.findFirst({
where: { id: product.id },
where: { id: productId },
include: userProductDetailInclude,
});
if (!detail) {
@@ -337,7 +395,10 @@ export class UserProductsService {
const technicalValues = dto.technicalValues ?? [];
this.validateTechnicalValues(form?.fields ?? [], technicalValues);
const slug = await this.ensureUniqueSlug(businessId, slugify(titleFa));
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() } : {}),
@@ -542,7 +603,7 @@ export class UserProductsService {
if (titleFa !== existing.title) {
slug = await this.ensureUniqueSlug(
businessId,
slugify(titleFa),
buildUserProductSlug(titleFa, dto.titleEn),
productId,
);
}
@@ -1272,6 +1333,7 @@ export class UserProductsService {
return {
id: product.id.toString(),
slug: product.slug,
pathSlug: buildUserProductSlug(product.title, titleEn),
title: product.title,
titleFa: product.title,
titleEn,