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/
|
||||
.DS_Store
|
||||
*.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.
|
||||
|
||||
### 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):
|
||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||
- `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)
|
||||
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||||
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||||
- **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.
|
||||
- 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
|
||||
Named slots the business dashboard can replace. Fetch once per page:
|
||||
|
||||
@@ -631,7 +631,7 @@
|
||||
"post": {
|
||||
"tags": ["Analytics"],
|
||||
"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": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
@@ -649,6 +649,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"home",
|
||||
"static_page",
|
||||
"portfolio_list",
|
||||
"portfolio_detail",
|
||||
"product_list",
|
||||
@@ -666,7 +667,7 @@
|
||||
"portfolio",
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -674,7 +675,7 @@
|
||||
},
|
||||
"path": {
|
||||
"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": {
|
||||
"200": {
|
||||
"description": "View recorded",
|
||||
"description": "View recorded (or skipped for bots)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "enum": [true] }
|
||||
"ok": { "type": "boolean", "enum": [true] },
|
||||
"skipped": { "type": "string", "enum": ["bot"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1747,6 +1747,7 @@ enum WebsitePageKind {
|
||||
video_detail
|
||||
user_product_list
|
||||
user_product_detail
|
||||
static_page
|
||||
|
||||
@@map("website_page_kind")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -99,16 +100,24 @@ export class PublicBlogsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'blog_list');
|
||||
return result;
|
||||
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@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);
|
||||
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'blog_detail',
|
||||
result.blog.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -127,9 +136,19 @@ export class PublicBlogsController {
|
||||
}
|
||||
|
||||
@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);
|
||||
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'blog_detail',
|
||||
result.blog.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -99,19 +100,24 @@ export class PublicPortfoliosController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'portfolio_list');
|
||||
return result;
|
||||
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:portfolioId')
|
||||
async getById(
|
||||
@Param('host') host: string,
|
||||
@Param('portfolioId') portfolioId: string,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -133,9 +139,19 @@ export class PublicPortfoliosController {
|
||||
}
|
||||
|
||||
@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);
|
||||
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'portfolio_detail',
|
||||
result.portfolio.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -234,10 +235,8 @@ export class PublicProductsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
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;
|
||||
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:productId/variations')
|
||||
@@ -265,9 +264,19 @@ export class PublicProductsController {
|
||||
}
|
||||
|
||||
@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);
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
@@ -297,9 +306,19 @@ export class PublicProductsController {
|
||||
}
|
||||
|
||||
@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);
|
||||
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);
|
||||
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,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -173,20 +174,25 @@ export class PublicStoreItemsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'store_item_list');
|
||||
return result;
|
||||
list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-product/:productId')
|
||||
async getByProduct(
|
||||
@Param('host') host: string,
|
||||
@Param('productId') productId: string,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
const result = await this.service.getPublicByProduct(host, productId);
|
||||
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;
|
||||
}
|
||||
@@ -195,12 +201,15 @@ export class PublicStoreItemsController {
|
||||
async getVariant(
|
||||
@Param('host') host: string,
|
||||
@Param('variantId') variantId: string,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
const result = await this.service.getPublicVariant(host, variantId);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'store_item_detail',
|
||||
result.variant.storeItemId,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
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,
|
||||
UpdateStoreSpecialDto,
|
||||
} 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';
|
||||
|
||||
@Controller('tenants/:host/store-specials')
|
||||
@@ -34,7 +36,10 @@ export class PublicStoreSpecialsController {
|
||||
@Controller('businesses/:businessId/store-specials')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class StoreSpecialsController {
|
||||
constructor(private readonly service: StoreSpecialsService) {}
|
||||
constructor(
|
||||
private readonly service: StoreSpecialsService,
|
||||
private readonly aiService: StoreSpecialsAiService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('products.read')
|
||||
@@ -46,6 +51,16 @@ export class StoreSpecialsController {
|
||||
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')
|
||||
@RequireBusinessPermission('products.read')
|
||||
getOne(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PublicStoreSpecialsController,
|
||||
StoreSpecialsController,
|
||||
} from './store-specials.controller';
|
||||
import { StoreSpecialsAiService } from './store-specials-ai.service';
|
||||
import { StoreSpecialsService } from './store-specials.service';
|
||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||
import { StoreItemsService } from './store-items.service';
|
||||
@@ -18,7 +19,7 @@ import { StoreItemsService } from './store-items.service';
|
||||
PublicStoreSpecialsController,
|
||||
StoreSpecialsController,
|
||||
],
|
||||
providers: [StoreItemsService, StoreSpecialsService],
|
||||
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
|
||||
exports: [StoreItemsService, StoreSpecialsService],
|
||||
})
|
||||
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 { UserProductsService } from './user-products.service';
|
||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||
@@ -11,13 +11,11 @@ export class PublicUserProductsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
list(
|
||||
@Param('host') host: string,
|
||||
@Query() query: ListPublicUserProductsDto,
|
||||
) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'user_product_list');
|
||||
return result;
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get('by-id/:productId/technical-info')
|
||||
@@ -32,12 +30,15 @@ export class PublicUserProductsController {
|
||||
async getById(
|
||||
@Param('host') host: string,
|
||||
@Param('productId') productId: string,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
const result = await this.service.getPublicById(host, productId);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'user_product_detail',
|
||||
result.product.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -48,12 +49,18 @@ export class PublicUserProductsController {
|
||||
}
|
||||
|
||||
@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);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'user_product_detail',
|
||||
result.product.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -99,10 +100,8 @@ export class PublicVideosController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||
const result = await this.service.listPublic(host, query);
|
||||
this.analytics.trackPublicPage(host, 'video_list');
|
||||
return result;
|
||||
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':videoId/comments')
|
||||
@@ -120,9 +119,19 @@ export class PublicVideosController {
|
||||
}
|
||||
|
||||
@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);
|
||||
this.analytics.trackPublicPage(host, 'video_detail', result.video.id);
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'video_detail',
|
||||
result.video.id,
|
||||
undefined,
|
||||
userAgent,
|
||||
);
|
||||
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 { WebsiteAnalyticsService } from './website-analytics.service';
|
||||
|
||||
@@ -7,7 +7,11 @@ export class PublicWebsiteAnalyticsController {
|
||||
constructor(private readonly service: WebsiteAnalyticsService) {}
|
||||
|
||||
@Post()
|
||||
record(@Param('host') host: string, @Body() dto: RecordWebsiteViewDto) {
|
||||
return this.service.recordPublicView(host, dto);
|
||||
record(
|
||||
@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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { isBotUserAgent } from './bot-user-agent';
|
||||
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
||||
import {
|
||||
normalizeWebsitePageKind,
|
||||
@@ -43,15 +44,18 @@ export class WebsiteAnalyticsService {
|
||||
|
||||
/**
|
||||
* Fire-and-forget friendly: record event + bump lifetime counter.
|
||||
* Used from public list/detail GET handlers after a successful response load.
|
||||
* `entityId` is trusted (already loaded by the caller); use 0 / omit for lists.
|
||||
* Used from public detail / static page GETs after a successful response load.
|
||||
* `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(
|
||||
host: string,
|
||||
kind: WebsitePageKind,
|
||||
entityId?: string | bigint | null,
|
||||
path?: string,
|
||||
userAgent?: string | null,
|
||||
): void {
|
||||
if (isBotUserAgent(userAgent)) return;
|
||||
void this.recordTrustedPageView(host, kind, entityId, path).catch((err) => {
|
||||
this.logger.warn(
|
||||
`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);
|
||||
if (!kind) {
|
||||
throw new BadRequestException(`Invalid view kind: ${dto.kind}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ export const WEBSITE_PAGE_KINDS = [
|
||||
'video_detail',
|
||||
'user_product_list',
|
||||
'user_product_detail',
|
||||
'static_page',
|
||||
] as const;
|
||||
|
||||
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 = [
|
||||
'all',
|
||||
'home',
|
||||
'pages',
|
||||
'products',
|
||||
'blog',
|
||||
'portfolio',
|
||||
@@ -88,17 +90,15 @@ export const VIEW_SUMMARY_GROUP_KINDS: Record<
|
||||
> = {
|
||||
all: null,
|
||||
home: ['home'],
|
||||
pages: ['static_page'],
|
||||
products: [
|
||||
'product_list',
|
||||
'product_detail',
|
||||
'store_item_list',
|
||||
'store_item_detail',
|
||||
'user_product_list',
|
||||
'user_product_detail',
|
||||
],
|
||||
blog: ['blog_list', 'blog_detail'],
|
||||
portfolio: ['portfolio_list', 'portfolio_detail'],
|
||||
videos: ['video_list', 'video_detail'],
|
||||
blog: ['blog_detail'],
|
||||
portfolio: ['portfolio_detail'],
|
||||
videos: ['video_detail'],
|
||||
};
|
||||
|
||||
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.
|
||||
|
||||
### 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):
|
||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||
- `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)
|
||||
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||||
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||||
- **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.
|
||||
- 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
|
||||
Named slots the business dashboard can replace. Fetch once per page:
|
||||
|
||||
@@ -631,7 +631,7 @@
|
||||
"post": {
|
||||
"tags": ["Analytics"],
|
||||
"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": [
|
||||
{
|
||||
"$ref": "#/components/parameters/domain"
|
||||
@@ -649,6 +649,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"home",
|
||||
"static_page",
|
||||
"portfolio_list",
|
||||
"portfolio_detail",
|
||||
"product_list",
|
||||
@@ -666,7 +667,7 @@
|
||||
"portfolio",
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -674,7 +675,7 @@
|
||||
},
|
||||
"path": {
|
||||
"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": {
|
||||
"200": {
|
||||
"description": "View recorded",
|
||||
"description": "View recorded (or skipped for bots)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||
import {
|
||||
CreateWebsiteSliderDto,
|
||||
ListWebsiteSlidersDto,
|
||||
@@ -24,17 +23,11 @@ import { WebsiteSlidersService } from './website-sliders.service';
|
||||
|
||||
@Controller('tenants/:host/website/sliders')
|
||||
export class PublicWebsiteSlidersController {
|
||||
constructor(
|
||||
private readonly service: WebsiteSlidersService,
|
||||
private readonly analytics: WebsiteAnalyticsService,
|
||||
) {}
|
||||
constructor(private readonly service: WebsiteSlidersService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Param('host') host: string) {
|
||||
const result = await this.service.listPublic(host);
|
||||
// Homepage signal: Meshkee storefronts load sliders on the home page.
|
||||
this.analytics.trackPublicPage(host, 'home');
|
||||
return result;
|
||||
list(@Param('host') host: string) {
|
||||
return this.service.listPublic(host);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -34,11 +35,23 @@ export class PublicWebsiteStaticImagesController {
|
||||
async list(
|
||||
@Param('host') host: string,
|
||||
@Query() query: PublicWebsiteStaticImagesQueryDto,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
const result = await this.service.listPublic(host, query.pageKey);
|
||||
const pageKey = query.pageKey?.trim() || 'home';
|
||||
if (pageKey === 'home') {
|
||||
this.analytics.trackPublicPage(host, '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') {
|
||||
this.analytics.trackPublicPage(host, 'home', null, undefined, userAgent);
|
||||
} else {
|
||||
this.analytics.trackPublicPage(
|
||||
host,
|
||||
'static_page',
|
||||
null,
|
||||
`/${pageKey}`,
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user