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:
Alireza Hassani
2026-09-16 17:33:44 +03:30
co-authored by Cursor
parent 6517877bed
commit b7ca306097
23 changed files with 688 additions and 85 deletions
+1
View File
@@ -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';
+13 -4
View File
@@ -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:
+7 -5
View File
@@ -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"] }
}
}
}
+1
View File
@@ -1747,6 +1747,7 @@ enum WebsitePageKind {
video_detail
user_product_list
user_product_detail
static_page
@@map("website_page_kind")
}
+27 -8
View File
@@ -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;
}
}
+23 -7
View File
@@ -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;
}
}
+27 -8
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class MatchStoreSpecialListAiDto {
@IsString()
@MinLength(3)
@MaxLength(20_000)
text!: string;
}
+14 -5
View File
@@ -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;
}
+440
View File
@@ -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`);
}
}
}
+16 -1
View File
@@ -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(
+2 -1
View File
@@ -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;
}
+15 -6
View File
@@ -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;
}
}
+12
View File
@@ -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 = {
+13 -4
View File
@@ -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:
+7 -5
View File
@@ -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"] }
}
}
}
+3 -10
View File
@@ -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;
}