Add page-view analytics, product counters, SSL expiry, and business renewal.
Auto-count storefront page views with 6-month event retention and lifetime counters, track product visit/cart/sale stats, persist SSL cert expiry with daily probes, and support owner titles plus annual contract renewal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6cd0b009ef
commit
92601ae729
@@ -15,6 +15,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
|
||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||
@@ -164,11 +165,14 @@ export class PublicProductsController {
|
||||
private readonly service: ProductsService,
|
||||
private readonly variationValuesService: ProductVariationValuesService,
|
||||
private readonly technicalInfoService: ProductTechnicalInfoService,
|
||||
private readonly analytics: WebsiteAnalyticsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
async list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'product_list');
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('by-id/:productId/variations')
|
||||
@@ -196,8 +200,11 @@ export class PublicProductsController {
|
||||
}
|
||||
|
||||
@Get('by-id/:productId')
|
||||
getById(@Param('host') host: string, @Param('productId') productId: string) {
|
||||
return this.service.getPublicById(host, productId);
|
||||
async getById(@Param('host') host: string, @Param('productId') productId: string) {
|
||||
const result = await this.service.getPublicById(host, productId);
|
||||
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
|
||||
this.service.bumpVisitCount(result.product.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':slug/variations')
|
||||
@@ -225,7 +232,10 @@ export class PublicProductsController {
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
const result = await this.service.getPublicBySlug(host, slug);
|
||||
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
|
||||
this.service.bumpVisitCount(result.product.id);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||
import { ProductAiService } from './product-ai.service';
|
||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||
import { ProductVariationValuesService } from './product-variation-values.service';
|
||||
@@ -12,7 +13,15 @@ import { ProductsController, PublicProductsController } from './products.control
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BrandsModule, CategoriesModule, MediaModule, SitemapModule, TenantModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
BrandsModule,
|
||||
CategoriesModule,
|
||||
MediaModule,
|
||||
SitemapModule,
|
||||
TenantModule,
|
||||
WebsiteAnalyticsModule,
|
||||
],
|
||||
controllers: [ProductsController, PublicProductsController],
|
||||
providers: [
|
||||
ProductsService,
|
||||
|
||||
@@ -602,11 +602,39 @@ export class ProductsService {
|
||||
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
||||
commentCount,
|
||||
variantCount: resolvedVariantCount,
|
||||
visitCount: product.visitCount.toString(),
|
||||
cartAddCount: product.cartAddCount.toString(),
|
||||
saleCount: product.saleCount.toString(),
|
||||
createdAt: product.createdAt,
|
||||
updatedAt: product.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bump product detail visit counter (fire-and-forget safe). */
|
||||
bumpVisitCount(productId: string | bigint): void {
|
||||
void this.incrementProductStat(productId, 'visitCount', 1).catch(() => undefined);
|
||||
}
|
||||
|
||||
bumpCartAddCount(productId: string | bigint, quantity = 1): void {
|
||||
void this.incrementProductStat(productId, 'cartAddCount', quantity).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async incrementProductStat(
|
||||
productIdRaw: string | bigint,
|
||||
field: 'visitCount' | 'cartAddCount' | 'saleCount',
|
||||
by: number,
|
||||
) {
|
||||
if (!Number.isFinite(by) || by <= 0) return;
|
||||
const productId =
|
||||
typeof productIdRaw === 'bigint' ? productIdRaw : BigInt(productIdRaw);
|
||||
await this.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: { [field]: { increment: by } },
|
||||
});
|
||||
}
|
||||
|
||||
private buildContent(nameFa?: string, descriptionHtml?: string) {
|
||||
return {
|
||||
nameFa: nameFa?.trim() || null,
|
||||
|
||||
Reference in New Issue
Block a user