Count page views once per load and skip known bots.
Stop auto-counting list APIs and homepage sliders, track static pages via pageKey, and add store-specials AI list matching. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6517877bed
commit
b7ca306097
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Count About / Contact / other static pages via static-images?pageKey=...
|
||||||
|
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'static_page';
|
||||||
@@ -198,13 +198,22 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||||||
|
|
||||||
### Analytics (dashboard charts)
|
### Analytics (dashboard charts)
|
||||||
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
Page views are recorded **automatically** for roughly one count per page load:
|
||||||
|
|
||||||
Optional explicit record (e.g. custom home without sliders):
|
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||||||
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
|
||||||
|
|
||||||
|
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
|
||||||
|
|
||||||
|
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
|
||||||
|
|
||||||
|
Optional explicit record (custom pages without static-images):
|
||||||
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||||
|
- `kind` values: `home`, `static_page`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted). Prefer detail / `home` / `static_page` for real page visits.
|
||||||
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
||||||
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
||||||
|
- The **Pages** statistics row counts `static_page` (about, contact, …).
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
|
|||||||
@@ -631,7 +631,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["Analytics"],
|
"tags": ["Analytics"],
|
||||||
"summary": "Record a storefront page view",
|
"summary": "Record a storefront page view",
|
||||||
"description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.",
|
"description": "Optional explicit view counter. Most page views are recorded automatically on: (1) GET website/static-images?pageKey=home → home, (2) GET static-images?pageKey=about|contact|… → static_page, (3) public detail GETs for products, user-products, blogs, portfolios, videos, store-items. List APIs and homepage sliders do not count. Known bots / empty User-Agent are skipped. Refresh counts again. Use this POST for custom pages that do not hit those endpoints. No auth required. Events retained ~6 months; lifetime counters kept forever.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -649,6 +649,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"home",
|
"home",
|
||||||
|
"static_page",
|
||||||
"portfolio_list",
|
"portfolio_list",
|
||||||
"portfolio_detail",
|
"portfolio_detail",
|
||||||
"product_list",
|
"product_list",
|
||||||
@@ -666,7 +667,7 @@
|
|||||||
"portfolio",
|
"portfolio",
|
||||||
"blog"
|
"blog"
|
||||||
],
|
],
|
||||||
"description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
|
"description": "Canonical kinds preferred. Prefer home, static_page, and *_detail for page visits. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
|
||||||
},
|
},
|
||||||
"entityId": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -674,7 +675,7 @@
|
|||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
|
"description": "Optional page path (e.g. `/`, `/about`, `/blog/my-post`). Recommended for static_page."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,13 +684,14 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "View recorded",
|
"description": "View recorded (or skipped for bots)",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"ok": { "type": "boolean", "enum": [true] }
|
"ok": { "type": "boolean", "enum": [true] },
|
||||||
|
"skipped": { "type": "string", "enum": ["bot"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1747,6 +1747,7 @@ enum WebsitePageKind {
|
|||||||
video_detail
|
video_detail
|
||||||
user_product_list
|
user_product_list
|
||||||
user_product_detail
|
user_product_detail
|
||||||
|
static_page
|
||||||
|
|
||||||
@@map("website_page_kind")
|
@@map("website_page_kind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -99,16 +100,24 @@ export class PublicBlogsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'blog_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:blogId')
|
@Get('by-id/:blogId')
|
||||||
async getById(@Param('host') host: string, @Param('blogId') blogId: string) {
|
async getById(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('blogId') blogId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicById(host, blogId);
|
const result = await this.service.getPublicById(host, blogId);
|
||||||
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'blog_detail',
|
||||||
|
result.blog.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +136,19 @@ export class PublicBlogsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'blog_detail',
|
||||||
|
result.blog.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -99,19 +100,24 @@ export class PublicPortfoliosController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'portfolio_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:portfolioId')
|
@Get('by-id/:portfolioId')
|
||||||
async getById(
|
async getById(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Param('portfolioId') portfolioId: string,
|
@Param('portfolioId') portfolioId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getPublicById(host, portfolioId);
|
const result = await this.service.getPublicById(host, portfolioId);
|
||||||
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'portfolio_detail',
|
||||||
|
result.portfolio.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +139,19 @@ export class PublicPortfoliosController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'portfolio_detail',
|
||||||
|
result.portfolio.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -234,10 +235,8 @@ export class PublicProductsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'product_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:productId/variations')
|
@Get('by-id/:productId/variations')
|
||||||
@@ -265,9 +264,19 @@ export class PublicProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:productId')
|
@Get('by-id/:productId')
|
||||||
async getById(@Param('host') host: string, @Param('productId') productId: string) {
|
async getById(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('productId') productId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicById(host, productId);
|
const result = await this.service.getPublicById(host, productId);
|
||||||
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'product_detail',
|
||||||
|
result.product.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
this.service.bumpVisitCount(result.product.id);
|
this.service.bumpVisitCount(result.product.id);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -297,9 +306,19 @@ export class PublicProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'product_detail',
|
||||||
|
result.product.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
this.service.bumpVisitCount(result.product.id);
|
this.service.bumpVisitCount(result.product.id);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class MatchStoreSpecialListAiDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(20_000)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -173,20 +174,25 @@ export class PublicStoreItemsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'store_item_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-product/:productId')
|
@Get('by-product/:productId')
|
||||||
async getByProduct(
|
async getByProduct(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getPublicByProduct(host, productId);
|
const result = await this.service.getPublicByProduct(host, productId);
|
||||||
if (result.storeItem) {
|
if (result.storeItem) {
|
||||||
this.analytics.trackPublicPage(host, 'store_item_detail', result.storeItem.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'store_item_detail',
|
||||||
|
result.storeItem.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -195,12 +201,15 @@ export class PublicStoreItemsController {
|
|||||||
async getVariant(
|
async getVariant(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Param('variantId') variantId: string,
|
@Param('variantId') variantId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getPublicVariant(host, variantId);
|
const result = await this.service.getPublicVariant(host, variantId);
|
||||||
this.analytics.trackPublicPage(
|
this.analytics.trackPublicPage(
|
||||||
host,
|
host,
|
||||||
'store_item_detail',
|
'store_item_detail',
|
||||||
result.variant.storeItemId,
|
result.variant.storeItemId,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import {
|
||||||
|
requestAiJsonCompletion,
|
||||||
|
resolveAiProvider,
|
||||||
|
} from '../common/ai-provider.util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { MatchStoreSpecialListAiDto } from './dto/store-specials-ai.dto';
|
||||||
|
|
||||||
|
type CatalogEntry = {
|
||||||
|
storeItemId: string;
|
||||||
|
productId: string;
|
||||||
|
title: string;
|
||||||
|
nameFa: string;
|
||||||
|
brandEn: string;
|
||||||
|
brandFa: string;
|
||||||
|
categories: string[];
|
||||||
|
image: string | null;
|
||||||
|
variantId: string;
|
||||||
|
variantLabel: string;
|
||||||
|
price: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiMatchRow = {
|
||||||
|
lineIndex?: number;
|
||||||
|
storeItemId?: string | null;
|
||||||
|
variantId?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
confidence?: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiMatchResponse = {
|
||||||
|
matches?: AiMatchRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_LINES = 40;
|
||||||
|
const MAX_CATALOG = 1500;
|
||||||
|
const SHORTLIST_SIZE = 24;
|
||||||
|
|
||||||
|
type ParsedLineHints = {
|
||||||
|
category: string | null;
|
||||||
|
brandTokens: string[];
|
||||||
|
modelTokens: string[];
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_HINTS: Array<{ key: string; aliases: string[] }> = [
|
||||||
|
{ key: 'steam-cleaner', aliases: ['بخار شوی', 'بخارشوی', 'steam cleaner', 'steamcleaner'] },
|
||||||
|
{ key: 'washing-machine', aliases: ['لباس شویی', 'لباسشویی', 'washing machine', 'washer'] },
|
||||||
|
{ key: 'dishwasher', aliases: ['ظرف شویی', 'ظرفشویی', 'dishwasher'] },
|
||||||
|
{ key: 'refrigerator', aliases: ['یخچال', 'فریزر', 'side by side', 'ساید', 'refrigerator'] },
|
||||||
|
{ key: 'dryer', aliases: ['سشوار', 'hair dryer', 'dryer'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BRAND_HINTS: Array<{ aliases: string[] }> = [
|
||||||
|
{ aliases: ['کارچر', 'karcher'] },
|
||||||
|
{ aliases: ['ال جی', 'الجی', 'lg'] },
|
||||||
|
{ aliases: ['دوو', 'daewoo'] },
|
||||||
|
{ aliases: ['انزو', 'enzo'] },
|
||||||
|
{ aliases: ['شیگلم', 'sheglam'] },
|
||||||
|
{ aliases: ['فیلیپس', 'philips'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreSpecialsAiService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async matchFromList(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: MatchStoreSpecialListAiDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
|
||||||
|
const lines = dto.text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/\u200c/g, ' ').replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_LINES);
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new BadRequestException('Paste at least one product line.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await this.loadCatalog(businessId);
|
||||||
|
if (catalog.length === 0) {
|
||||||
|
throw new BadRequestException('No store items found for this business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMatches = await Promise.all(
|
||||||
|
lines.map(async (raw, lineIndex) => {
|
||||||
|
const hints = this.parseLineHints(raw);
|
||||||
|
const candidates = this.shortlistCandidates(hints, catalog).slice(0, SHORTLIST_SIZE);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { lineIndex, storeItemId: null, variantId: null, confidence: 0, reason: 'no shortlist' };
|
||||||
|
}
|
||||||
|
return this.askAiToMatchOne(lineIndex, raw, hints, candidates);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const matches = lines.map((raw, lineIndex) => {
|
||||||
|
const ai = aiMatches[lineIndex];
|
||||||
|
const storeItemId = ai?.storeItemId ? String(ai.storeItemId) : null;
|
||||||
|
const entry =
|
||||||
|
storeItemId
|
||||||
|
? catalog.find((candidate) => candidate.storeItemId === storeItemId)
|
||||||
|
: undefined;
|
||||||
|
const confidence =
|
||||||
|
typeof ai?.confidence === 'number' && Number.isFinite(ai.confidence)
|
||||||
|
? Math.max(0, Math.min(1, ai.confidence))
|
||||||
|
: entry
|
||||||
|
? 0.7
|
||||||
|
: 0;
|
||||||
|
const price =
|
||||||
|
typeof ai?.price === 'number' && Number.isFinite(ai.price) && ai.price > 0
|
||||||
|
? Math.round(ai.price)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!entry || confidence < 0.35) {
|
||||||
|
return {
|
||||||
|
lineIndex,
|
||||||
|
raw,
|
||||||
|
price,
|
||||||
|
selected: false,
|
||||||
|
updatePrice: false,
|
||||||
|
candidate: null as null,
|
||||||
|
confidence,
|
||||||
|
reason: ai?.reason ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantId =
|
||||||
|
ai?.variantId && entry.variantId === String(ai.variantId)
|
||||||
|
? entry.variantId
|
||||||
|
: entry.variantId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineIndex,
|
||||||
|
raw,
|
||||||
|
price,
|
||||||
|
selected: true,
|
||||||
|
updatePrice: price != null,
|
||||||
|
confidence,
|
||||||
|
reason: ai?.reason ?? null,
|
||||||
|
candidate: {
|
||||||
|
storeItemId: entry.storeItemId,
|
||||||
|
productId: entry.productId,
|
||||||
|
productTitle: entry.title,
|
||||||
|
productNameFa: entry.nameFa,
|
||||||
|
productImage: entry.image,
|
||||||
|
variantId,
|
||||||
|
variantLabel: entry.variantLabel,
|
||||||
|
currentPrice: entry.price,
|
||||||
|
score: confidence,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'AI matched special-item list.',
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCatalog(businessId: bigint): Promise<CatalogEntry[]> {
|
||||||
|
const storeItems = await this.prisma.storeItem.findMany({
|
||||||
|
where: { businessId, isActive: true },
|
||||||
|
take: MAX_CATALOG,
|
||||||
|
orderBy: [{ updatedAt: 'desc' }],
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: { featuredMedia: true, brand: true },
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
take: 8,
|
||||||
|
include: {
|
||||||
|
selections: {
|
||||||
|
include: { option: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const productIds = storeItems.map((item) => item.productId);
|
||||||
|
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: {
|
||||||
|
select: { name: true, nameFa: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const categoriesByProductId = new Map<string, string[]>();
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const key = assignment.entityId.toString();
|
||||||
|
const list = categoriesByProductId.get(key) ?? [];
|
||||||
|
if (assignment.category.nameFa) list.push(assignment.category.nameFa);
|
||||||
|
list.push(assignment.category.name);
|
||||||
|
categoriesByProductId.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: CatalogEntry[] = [];
|
||||||
|
for (const item of storeItems) {
|
||||||
|
const variant =
|
||||||
|
item.variants.find((v) => v.price !== null) ?? item.variants[0] ?? null;
|
||||||
|
if (!variant) continue;
|
||||||
|
|
||||||
|
const content =
|
||||||
|
item.product.content &&
|
||||||
|
typeof item.product.content === 'object' &&
|
||||||
|
!Array.isArray(item.product.content)
|
||||||
|
? (item.product.content as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const nameFa = typeof content.nameFa === 'string' ? content.nameFa : '';
|
||||||
|
const label =
|
||||||
|
variant.selections.map((s) => s.option.label).filter(Boolean).join(' · ') ||
|
||||||
|
item.product.title;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
storeItemId: item.id.toString(),
|
||||||
|
productId: item.productId.toString(),
|
||||||
|
title: item.product.title,
|
||||||
|
nameFa,
|
||||||
|
brandEn: item.product.brand?.nameEn ?? '',
|
||||||
|
brandFa: item.product.brand?.nameFa ?? '',
|
||||||
|
categories: categoriesByProductId.get(item.productId.toString()) ?? [],
|
||||||
|
image: item.product.featuredMedia?.publicUrl ?? null,
|
||||||
|
variantId: variant.id.toString(),
|
||||||
|
variantLabel: label,
|
||||||
|
price: variant.price === null ? null : Number(variant.price),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async askAiToMatchOne(
|
||||||
|
lineIndex: number,
|
||||||
|
raw: string,
|
||||||
|
hints: ParsedLineHints,
|
||||||
|
catalog: CatalogEntry[],
|
||||||
|
) {
|
||||||
|
const provider = resolveAiProvider(this.config);
|
||||||
|
|
||||||
|
const compactCatalog = catalog.map((entry) => ({
|
||||||
|
id: entry.storeItemId,
|
||||||
|
t: entry.title,
|
||||||
|
fa: entry.nameFa,
|
||||||
|
b: [entry.brandFa, entry.brandEn].filter(Boolean).join(' / '),
|
||||||
|
c: entry.categories.slice(0, 3).join(' / '),
|
||||||
|
v: entry.variantId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const systemPrompt = `You match messy Iranian shopkeeper product lines to a store catalog.
|
||||||
|
|
||||||
|
Return ONLY valid JSON:
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"lineIndex": 0,
|
||||||
|
"storeItemId": "123" | null,
|
||||||
|
"variantId": "456" | null,
|
||||||
|
"price": 605000000 | null,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"reason": "short"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Match ONLY this one input line.
|
||||||
|
- storeItemId MUST be an id from the provided candidate catalog, or null if nothing is close.
|
||||||
|
- Priority: category first, then brand, then model code / product name.
|
||||||
|
- If category conflicts badly (example: steam cleaner vs hair dryer), return null even if some words overlap.
|
||||||
|
- Prefer brand + model code matches (e.g. SC2, X267, LG) over weak word overlap.
|
||||||
|
- Names differ a lot: Persian vs English, missing/extra spaces (بخارشوی vs بخار شوی), typos, warranty fluff, "ساخت آلمان/کره", import notes — ignore fluff and match meaning.
|
||||||
|
- confidence 0..1 (use >= 0.55 only when reasonably sure).
|
||||||
|
- price: absolute IRT integer when the line states a price.
|
||||||
|
* "۶۰۵ میلیون" / "605 million" → 605000000
|
||||||
|
* "قیمت ۸/۲۰۰" informal → 8200000 (millions/thousands)
|
||||||
|
* "قیمت ۶" small integer with قیمت → 6000000
|
||||||
|
* If no price in line → null
|
||||||
|
- Do not invent catalog ids.`;
|
||||||
|
|
||||||
|
const userPrompt = JSON.stringify({
|
||||||
|
line: { lineIndex, text: raw, hints },
|
||||||
|
catalog: compactCatalog,
|
||||||
|
});
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await requestAiJsonCompletion(
|
||||||
|
provider,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: AiMatchResponse;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content) as AiMatchResponse;
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('AI returned invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed.matches) || parsed.matches.length === 0) {
|
||||||
|
return { lineIndex, storeItemId: null, variantId: null, confidence: 0, reason: 'empty ai response' };
|
||||||
|
}
|
||||||
|
return parsed.matches[0] ?? { lineIndex, storeItemId: null, variantId: null, confidence: 0, reason: 'empty ai row' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLineHints(raw: string): ParsedLineHints {
|
||||||
|
const text = this.normalize(raw)
|
||||||
|
.replace(/\bساخت\s+\S+/g, ' ')
|
||||||
|
.replace(/\bقیمت\b[\s\S]*$/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const category =
|
||||||
|
CATEGORY_HINTS.find((entry) =>
|
||||||
|
entry.aliases.some((alias) => text.includes(this.normalize(alias))),
|
||||||
|
)?.key ?? null;
|
||||||
|
|
||||||
|
const brandTokens = BRAND_HINTS
|
||||||
|
.filter((entry) => entry.aliases.some((alias) => text.includes(this.normalize(alias))))
|
||||||
|
.flatMap((entry) => entry.aliases.map((alias) => this.normalize(alias)));
|
||||||
|
|
||||||
|
const modelTokens = Array.from(
|
||||||
|
new Set(
|
||||||
|
text.match(/[a-z]+\s*-?\s*\d+[a-z0-9-]*/gi)?.map((token) => token.replace(/\s+/g, '')) ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { category, brandTokens, modelTokens, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
private shortlistCandidates(hints: ParsedLineHints, catalog: CatalogEntry[]) {
|
||||||
|
return catalog
|
||||||
|
.map((entry) => ({
|
||||||
|
entry,
|
||||||
|
score: this.scoreCandidate(hints, entry),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map((row) => row.entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scoreCandidate(hints: ParsedLineHints, entry: CatalogEntry) {
|
||||||
|
const hay = this.normalize(
|
||||||
|
[
|
||||||
|
entry.title,
|
||||||
|
entry.nameFa,
|
||||||
|
entry.brandEn,
|
||||||
|
entry.brandFa,
|
||||||
|
entry.variantLabel,
|
||||||
|
...entry.categories,
|
||||||
|
].join(' '),
|
||||||
|
);
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (hints.category) {
|
||||||
|
const categoryMatched = this.categoryMatches(hints.category, hay);
|
||||||
|
if (!categoryMatched) return 0;
|
||||||
|
score += 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hints.brandTokens.length > 0) {
|
||||||
|
const brandMatched = hints.brandTokens.some((token) => hay.includes(token));
|
||||||
|
if (!brandMatched) return 0;
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of hints.modelTokens) {
|
||||||
|
if (hay.includes(this.normalize(token))) score += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = hints.text.split(' ').filter((part) => part.length >= 3);
|
||||||
|
for (const word of words) {
|
||||||
|
if (hay.includes(word)) score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private categoryMatches(category: string, hay: string) {
|
||||||
|
const aliases = CATEGORY_HINTS.find((entry) => entry.key === category)?.aliases ?? [];
|
||||||
|
return aliases.some((alias) => hay.includes(this.normalize(alias)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ي/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/بخارشوی/g, 'بخار شوی')
|
||||||
|
.replace(/ظرفشویی/g, 'ظرف شویی')
|
||||||
|
.replace(/لباسشویی/g, 'لباس شویی')
|
||||||
|
.replace(/[^\p{L}\p{N}\s/-]/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
ListStoreSpecialsDto,
|
ListStoreSpecialsDto,
|
||||||
UpdateStoreSpecialDto,
|
UpdateStoreSpecialDto,
|
||||||
} from './dto/store-specials.dto';
|
} from './dto/store-specials.dto';
|
||||||
|
import { MatchStoreSpecialListAiDto } from './dto/store-specials-ai.dto';
|
||||||
|
import { StoreSpecialsAiService } from './store-specials-ai.service';
|
||||||
import { StoreSpecialsService } from './store-specials.service';
|
import { StoreSpecialsService } from './store-specials.service';
|
||||||
|
|
||||||
@Controller('tenants/:host/store-specials')
|
@Controller('tenants/:host/store-specials')
|
||||||
@@ -34,7 +36,10 @@ export class PublicStoreSpecialsController {
|
|||||||
@Controller('businesses/:businessId/store-specials')
|
@Controller('businesses/:businessId/store-specials')
|
||||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
export class StoreSpecialsController {
|
export class StoreSpecialsController {
|
||||||
constructor(private readonly service: StoreSpecialsService) {}
|
constructor(
|
||||||
|
private readonly service: StoreSpecialsService,
|
||||||
|
private readonly aiService: StoreSpecialsAiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireBusinessPermission('products.read')
|
@RequireBusinessPermission('products.read')
|
||||||
@@ -46,6 +51,16 @@ export class StoreSpecialsController {
|
|||||||
return this.service.list(businessId, query, user);
|
return this.service.list(businessId, query, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('ai-match')
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
matchWithAi(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: MatchStoreSpecialListAiDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.aiService.matchFromList(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':specialId')
|
@Get(':specialId')
|
||||||
@RequireBusinessPermission('products.read')
|
@RequireBusinessPermission('products.read')
|
||||||
getOne(
|
getOne(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PublicStoreSpecialsController,
|
PublicStoreSpecialsController,
|
||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
} from './store-specials.controller';
|
} from './store-specials.controller';
|
||||||
|
import { StoreSpecialsAiService } from './store-specials-ai.service';
|
||||||
import { StoreSpecialsService } from './store-specials.service';
|
import { StoreSpecialsService } from './store-specials.service';
|
||||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||||
import { StoreItemsService } from './store-items.service';
|
import { StoreItemsService } from './store-items.service';
|
||||||
@@ -18,7 +19,7 @@ import { StoreItemsService } from './store-items.service';
|
|||||||
PublicStoreSpecialsController,
|
PublicStoreSpecialsController,
|
||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
],
|
],
|
||||||
providers: [StoreItemsService, StoreSpecialsService],
|
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
|
||||||
exports: [StoreItemsService, StoreSpecialsService],
|
exports: [StoreItemsService, StoreSpecialsService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Headers, Param, Query } from '@nestjs/common';
|
||||||
import { ListPublicUserProductsDto } from './dto/user-product.dto';
|
import { ListPublicUserProductsDto } from './dto/user-product.dto';
|
||||||
import { UserProductsService } from './user-products.service';
|
import { UserProductsService } from './user-products.service';
|
||||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
@@ -11,13 +11,11 @@ export class PublicUserProductsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
list(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Query() query: ListPublicUserProductsDto,
|
@Query() query: ListPublicUserProductsDto,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'user_product_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:productId/technical-info')
|
@Get('by-id/:productId/technical-info')
|
||||||
@@ -32,12 +30,15 @@ export class PublicUserProductsController {
|
|||||||
async getById(
|
async getById(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getPublicById(host, productId);
|
const result = await this.service.getPublicById(host, productId);
|
||||||
this.analytics.trackPublicPage(
|
this.analytics.trackPublicPage(
|
||||||
host,
|
host,
|
||||||
'user_product_detail',
|
'user_product_detail',
|
||||||
result.product.id,
|
result.product.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -48,12 +49,18 @@ export class PublicUserProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
this.analytics.trackPublicPage(
|
this.analytics.trackPublicPage(
|
||||||
host,
|
host,
|
||||||
'user_product_detail',
|
'user_product_detail',
|
||||||
result.product.id,
|
result.product.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -99,10 +100,8 @@ export class PublicVideosController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||||
const result = await this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
this.analytics.trackPublicPage(host, 'video_list');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':videoId/comments')
|
@Get(':videoId/comments')
|
||||||
@@ -120,9 +119,19 @@ export class PublicVideosController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
const result = await this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
this.analytics.trackPublicPage(host, 'video_detail', result.video.id);
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'video_detail',
|
||||||
|
result.video.id,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Detect common crawlers / scrapers from User-Agent.
|
||||||
|
* Empty / missing UA is treated as non-browser traffic and skipped.
|
||||||
|
*/
|
||||||
|
const BOT_UA_PATTERN =
|
||||||
|
/bot|crawler|spider|crawling|slurp|scrapy|curl\/|wget|python-requests|httpclient|go-http-client|java\/|libwww|okhttp|phantomjs|headless|lighthouse|semrush|ahrefs|mj12bot|dotbot|petalbot|bytespider|gptbot|claudebot|anthropic|bingpreview|facebookexternalhit|twitterbot|linkedinbot|discordbot|whatsapp|telegram|yandex|baiduspider|duckduck|sogou|exabot|ia_archiver|archive\.org|googlebot|bingbot|applebot|duckduckbot|msnbot|adsbot|mediapartners-google|apis-google|feedfetcher|storebot|google-inspectiontool|chrome-lighthouse/i;
|
||||||
|
|
||||||
|
export function isBotUserAgent(userAgent: string | null | undefined): boolean {
|
||||||
|
const ua = typeof userAgent === 'string' ? userAgent.trim() : '';
|
||||||
|
if (!ua) return true;
|
||||||
|
return BOT_UA_PATTERN.test(ua);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Headers, Param, Post } from '@nestjs/common';
|
||||||
import { RecordWebsiteViewDto } from './dto/record-view.dto';
|
import { RecordWebsiteViewDto } from './dto/record-view.dto';
|
||||||
import { WebsiteAnalyticsService } from './website-analytics.service';
|
import { WebsiteAnalyticsService } from './website-analytics.service';
|
||||||
|
|
||||||
@@ -7,7 +7,11 @@ export class PublicWebsiteAnalyticsController {
|
|||||||
constructor(private readonly service: WebsiteAnalyticsService) {}
|
constructor(private readonly service: WebsiteAnalyticsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
record(@Param('host') host: string, @Body() dto: RecordWebsiteViewDto) {
|
record(
|
||||||
return this.service.recordPublicView(host, dto);
|
@Param('host') host: string,
|
||||||
|
@Body() dto: RecordWebsiteViewDto,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
|
) {
|
||||||
|
return this.service.recordPublicView(host, dto, userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import { isBotUserAgent } from './bot-user-agent';
|
||||||
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
||||||
import {
|
import {
|
||||||
normalizeWebsitePageKind,
|
normalizeWebsitePageKind,
|
||||||
@@ -43,15 +44,18 @@ export class WebsiteAnalyticsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget friendly: record event + bump lifetime counter.
|
* Fire-and-forget friendly: record event + bump lifetime counter.
|
||||||
* Used from public list/detail GET handlers after a successful response load.
|
* Used from public detail / static page GETs after a successful response load.
|
||||||
* `entityId` is trusted (already loaded by the caller); use 0 / omit for lists.
|
* `entityId` is trusted (already loaded by the caller); use 0 / omit for home/static.
|
||||||
|
* Known bots (and empty User-Agent) are skipped. Refresh still counts again.
|
||||||
*/
|
*/
|
||||||
trackPublicPage(
|
trackPublicPage(
|
||||||
host: string,
|
host: string,
|
||||||
kind: WebsitePageKind,
|
kind: WebsitePageKind,
|
||||||
entityId?: string | bigint | null,
|
entityId?: string | bigint | null,
|
||||||
path?: string,
|
path?: string,
|
||||||
|
userAgent?: string | null,
|
||||||
): void {
|
): void {
|
||||||
|
if (isBotUserAgent(userAgent)) return;
|
||||||
void this.recordTrustedPageView(host, kind, entityId, path).catch((err) => {
|
void this.recordTrustedPageView(host, kind, entityId, path).catch((err) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`trackPublicPage failed host=${host} kind=${kind}: ${
|
`trackPublicPage failed host=${host} kind=${kind}: ${
|
||||||
@@ -61,7 +65,14 @@ export class WebsiteAnalyticsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
|
async recordPublicView(
|
||||||
|
host: string,
|
||||||
|
dto: RecordWebsiteViewDto,
|
||||||
|
userAgent?: string | null,
|
||||||
|
) {
|
||||||
|
if (isBotUserAgent(userAgent)) {
|
||||||
|
return { ok: true as const, skipped: 'bot' as const };
|
||||||
|
}
|
||||||
const kind = normalizeWebsitePageKind(dto.kind);
|
const kind = normalizeWebsitePageKind(dto.kind);
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
throw new BadRequestException(`Invalid view kind: ${dto.kind}`);
|
throw new BadRequestException(`Invalid view kind: ${dto.kind}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const WEBSITE_PAGE_KINDS = [
|
|||||||
'video_detail',
|
'video_detail',
|
||||||
'user_product_list',
|
'user_product_list',
|
||||||
'user_product_detail',
|
'user_product_detail',
|
||||||
|
'static_page',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number];
|
export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number];
|
||||||
@@ -74,6 +75,7 @@ export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind | 'all
|
|||||||
export const VIEW_SUMMARY_GROUP_IDS = [
|
export const VIEW_SUMMARY_GROUP_IDS = [
|
||||||
'all',
|
'all',
|
||||||
'home',
|
'home',
|
||||||
|
'pages',
|
||||||
'products',
|
'products',
|
||||||
'blog',
|
'blog',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
@@ -88,17 +90,15 @@ export const VIEW_SUMMARY_GROUP_KINDS: Record<
|
|||||||
> = {
|
> = {
|
||||||
all: null,
|
all: null,
|
||||||
home: ['home'],
|
home: ['home'],
|
||||||
|
pages: ['static_page'],
|
||||||
products: [
|
products: [
|
||||||
'product_list',
|
|
||||||
'product_detail',
|
'product_detail',
|
||||||
'store_item_list',
|
|
||||||
'store_item_detail',
|
'store_item_detail',
|
||||||
'user_product_list',
|
|
||||||
'user_product_detail',
|
'user_product_detail',
|
||||||
],
|
],
|
||||||
blog: ['blog_list', 'blog_detail'],
|
blog: ['blog_detail'],
|
||||||
portfolio: ['portfolio_list', 'portfolio_detail'],
|
portfolio: ['portfolio_detail'],
|
||||||
videos: ['video_list', 'video_detail'],
|
videos: ['video_detail'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViewPeriodCounts = {
|
export type ViewPeriodCounts = {
|
||||||
|
|||||||
@@ -198,13 +198,22 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||||||
|
|
||||||
### Analytics (dashboard charts)
|
### Analytics (dashboard charts)
|
||||||
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
Page views are recorded **automatically** for roughly one count per page load:
|
||||||
|
|
||||||
Optional explicit record (e.g. custom home without sliders):
|
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||||||
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
|
||||||
|
|
||||||
|
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
|
||||||
|
|
||||||
|
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
|
||||||
|
|
||||||
|
Optional explicit record (custom pages without static-images):
|
||||||
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||||
|
- `kind` values: `home`, `static_page`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted). Prefer detail / `home` / `static_page` for real page visits.
|
||||||
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
||||||
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
||||||
|
- The **Pages** statistics row counts `static_page` (about, contact, …).
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
|
|||||||
@@ -631,7 +631,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["Analytics"],
|
"tags": ["Analytics"],
|
||||||
"summary": "Record a storefront page view",
|
"summary": "Record a storefront page view",
|
||||||
"description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.",
|
"description": "Optional explicit view counter. Most page views are recorded automatically on: (1) GET website/static-images?pageKey=home → home, (2) GET static-images?pageKey=about|contact|… → static_page, (3) public detail GETs for products, user-products, blogs, portfolios, videos, store-items. List APIs and homepage sliders do not count. Known bots / empty User-Agent are skipped. Refresh counts again. Use this POST for custom pages that do not hit those endpoints. No auth required. Events retained ~6 months; lifetime counters kept forever.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -649,6 +649,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"home",
|
"home",
|
||||||
|
"static_page",
|
||||||
"portfolio_list",
|
"portfolio_list",
|
||||||
"portfolio_detail",
|
"portfolio_detail",
|
||||||
"product_list",
|
"product_list",
|
||||||
@@ -666,7 +667,7 @@
|
|||||||
"portfolio",
|
"portfolio",
|
||||||
"blog"
|
"blog"
|
||||||
],
|
],
|
||||||
"description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
|
"description": "Canonical kinds preferred. Prefer home, static_page, and *_detail for page visits. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
|
||||||
},
|
},
|
||||||
"entityId": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -674,7 +675,7 @@
|
|||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
|
"description": "Optional page path (e.g. `/`, `/about`, `/blog/my-post`). Recommended for static_page."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,13 +684,14 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "View recorded",
|
"description": "View recorded (or skipped for bots)",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"ok": { "type": "boolean", "enum": [true] }
|
"ok": { "type": "boolean", "enum": [true] },
|
||||||
|
"skipped": { "type": "string", "enum": ["bot"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
|
||||||
import {
|
import {
|
||||||
CreateWebsiteSliderDto,
|
CreateWebsiteSliderDto,
|
||||||
ListWebsiteSlidersDto,
|
ListWebsiteSlidersDto,
|
||||||
@@ -24,17 +23,11 @@ import { WebsiteSlidersService } from './website-sliders.service';
|
|||||||
|
|
||||||
@Controller('tenants/:host/website/sliders')
|
@Controller('tenants/:host/website/sliders')
|
||||||
export class PublicWebsiteSlidersController {
|
export class PublicWebsiteSlidersController {
|
||||||
constructor(
|
constructor(private readonly service: WebsiteSlidersService) {}
|
||||||
private readonly service: WebsiteSlidersService,
|
|
||||||
private readonly analytics: WebsiteAnalyticsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Param('host') host: string) {
|
list(@Param('host') host: string) {
|
||||||
const result = await this.service.listPublic(host);
|
return this.service.listPublic(host);
|
||||||
// Homepage signal: Meshkee storefronts load sliders on the home page.
|
|
||||||
this.analytics.trackPublicPage(host, 'home');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -34,11 +35,23 @@ export class PublicWebsiteStaticImagesController {
|
|||||||
async list(
|
async list(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Query() query: PublicWebsiteStaticImagesQueryDto,
|
@Query() query: PublicWebsiteStaticImagesQueryDto,
|
||||||
|
@Headers('user-agent') userAgent?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.listPublic(host, query.pageKey);
|
const result = await this.service.listPublic(host, query.pageKey);
|
||||||
const pageKey = query.pageKey?.trim() || 'home';
|
const pageKey = query.pageKey?.trim();
|
||||||
|
// Only count when the storefront loads a specific page's slots (home / about / contact / …).
|
||||||
|
if (pageKey) {
|
||||||
if (pageKey === 'home') {
|
if (pageKey === 'home') {
|
||||||
this.analytics.trackPublicPage(host, 'home');
|
this.analytics.trackPublicPage(host, 'home', null, undefined, userAgent);
|
||||||
|
} else {
|
||||||
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'static_page',
|
||||||
|
null,
|
||||||
|
`/${pageKey}`,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user