From b7ca306097dcc5d13507948ca1a153d342b29327 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 16 Sep 2026 17:33:44 +0330 Subject: [PATCH] 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 --- .gitignore | 1 + .../093_website_page_kind_static_page.sql | 3 + docs/website-api/AI_PROMPT.md | 17 +- docs/website-api/openapi.json | 12 +- prisma/schema.prisma | 1 + src/blogs/blogs.controller.ts | 35 +- src/portfolios/portfolios.controller.ts | 30 +- src/products/products.controller.ts | 35 +- src/store/dto/store-specials-ai.dto.ts | 8 + src/store/store-items.controller.ts | 19 +- src/store/store-specials-ai.service.ts | 440 ++++++++++++++++++ src/store/store-specials.controller.ts | 17 +- src/store/store.module.ts | 3 +- .../user-products.public.controller.ts | 19 +- src/videos/videos.controller.ts | 21 +- src/website-analytics/bot-user-agent.ts | 12 + .../public-website-analytics.controller.ts | 10 +- .../website-analytics.service.ts | 17 +- .../website-analytics.types.ts | 12 +- src/website-docs/static/AI_PROMPT.md | 17 +- src/website-docs/static/openapi.json | 12 +- src/website/website-sliders.controller.ts | 13 +- .../website-static-images.controller.ts | 19 +- 23 files changed, 688 insertions(+), 85 deletions(-) create mode 100644 database/migrations/093_website_page_kind_static_page.sql create mode 100644 src/store/dto/store-specials-ai.dto.ts create mode 100644 src/store/store-specials-ai.service.ts create mode 100644 src/website-analytics/bot-user-agent.ts diff --git a/.gitignore b/.gitignore index f1ec73a..1d40411 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ .DS_Store *.log +tmp/ diff --git a/database/migrations/093_website_page_kind_static_page.sql b/database/migrations/093_website_page_kind_static_page.sql new file mode 100644 index 0000000..ebfde7b --- /dev/null +++ b/database/migrations/093_website_page_kind_static_page.sql @@ -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'; diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 6a1a5d1..c0581d8 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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: diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 4c29c8d..78b1746 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -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"] } } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39f601c..294a131 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1747,6 +1747,7 @@ enum WebsitePageKind { video_detail user_product_list user_product_detail + static_page @@map("website_page_kind") } diff --git a/src/blogs/blogs.controller.ts b/src/blogs/blogs.controller.ts index 42ccc81..cb0cc08 100644 --- a/src/blogs/blogs.controller.ts +++ b/src/blogs/blogs.controller.ts @@ -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; } } diff --git a/src/portfolios/portfolios.controller.ts b/src/portfolios/portfolios.controller.ts index bac6b0d..71c1023 100644 --- a/src/portfolios/portfolios.controller.ts +++ b/src/portfolios/portfolios.controller.ts @@ -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; } } diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index aa8f62b..158a583 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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; } diff --git a/src/store/dto/store-specials-ai.dto.ts b/src/store/dto/store-specials-ai.dto.ts new file mode 100644 index 0000000..4d49aab --- /dev/null +++ b/src/store/dto/store-specials-ai.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class MatchStoreSpecialListAiDto { + @IsString() + @MinLength(3) + @MaxLength(20_000) + text!: string; +} diff --git a/src/store/store-items.controller.ts b/src/store/store-items.controller.ts index 82123a0..cf2b804 100644 --- a/src/store/store-items.controller.ts +++ b/src/store/store-items.controller.ts @@ -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; } diff --git a/src/store/store-specials-ai.service.ts b/src/store/store-specials-ai.service.ts new file mode 100644 index 0000000..2b422c6 --- /dev/null +++ b/src/store/store-specials-ai.service.ts @@ -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 { + 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(); + 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) + : {}; + 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`); + } + } +} diff --git a/src/store/store-specials.controller.ts b/src/store/store-specials.controller.ts index b8bbd7d..e1435dd 100644 --- a/src/store/store-specials.controller.ts +++ b/src/store/store-specials.controller.ts @@ -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( diff --git a/src/store/store.module.ts b/src/store/store.module.ts index 6117903..cde8ec6 100644 --- a/src/store/store.module.ts +++ b/src/store/store.module.ts @@ -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 {} diff --git a/src/user-products/user-products.public.controller.ts b/src/user-products/user-products.public.controller.ts index e60d208..250ab45 100644 --- a/src/user-products/user-products.public.controller.ts +++ b/src/user-products/user-products.public.controller.ts @@ -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; } diff --git a/src/videos/videos.controller.ts b/src/videos/videos.controller.ts index 028b1b8..e73d599 100644 --- a/src/videos/videos.controller.ts +++ b/src/videos/videos.controller.ts @@ -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; } } diff --git a/src/website-analytics/bot-user-agent.ts b/src/website-analytics/bot-user-agent.ts new file mode 100644 index 0000000..2bf17d3 --- /dev/null +++ b/src/website-analytics/bot-user-agent.ts @@ -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); +} diff --git a/src/website-analytics/public-website-analytics.controller.ts b/src/website-analytics/public-website-analytics.controller.ts index 3d878b5..176be84 100644 --- a/src/website-analytics/public-website-analytics.controller.ts +++ b/src/website-analytics/public-website-analytics.controller.ts @@ -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); } } diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts index d83b566..b2db6e4 100644 --- a/src/website-analytics/website-analytics.service.ts +++ b/src/website-analytics/website-analytics.service.ts @@ -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}`); diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts index 7d8fad9..f45a30c 100644 --- a/src/website-analytics/website-analytics.types.ts +++ b/src/website-analytics/website-analytics.types.ts @@ -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 = { 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 = { diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 6a1a5d1..c0581d8 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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: diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 4c29c8d..78b1746 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -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"] } } } } diff --git a/src/website/website-sliders.controller.ts b/src/website/website-sliders.controller.ts index c80002f..b7365d8 100644 --- a/src/website/website-sliders.controller.ts +++ b/src/website/website-sliders.controller.ts @@ -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); } } diff --git a/src/website/website-static-images.controller.ts b/src/website/website-static-images.controller.ts index d107490..4d85f60 100644 --- a/src/website/website-static-images.controller.ts +++ b/src/website/website-static-images.controller.ts @@ -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; }