From 0c1c3f7ff931b3aac06ca3bdbb4028b57352e3b5 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 28 Aug 2026 20:39:37 +0330 Subject: [PATCH] Add AI English name translation and aggregate website view analytics. Website views chart uses kind=all across page kinds; home hits also count static-images loads. Co-authored-by: Cursor --- src/products/dto/product-ai.dto.ts | 12 +++-- src/products/product-ai.service.ts | 53 ++++++++++++++++++- .../dto/view-activity-query.dto.ts | 1 + .../website-analytics.service.ts | 48 +++++++++++------ .../website-analytics.types.ts | 6 +-- .../website-static-images.controller.ts | 15 ++++-- 6 files changed, 108 insertions(+), 27 deletions(-) diff --git a/src/products/dto/product-ai.dto.ts b/src/products/dto/product-ai.dto.ts index 97cc6da..2656a74 100644 --- a/src/products/dto/product-ai.dto.ts +++ b/src/products/dto/product-ai.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsEnum, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator'; export enum ProductAiLanguage { en = 'en', @@ -8,6 +8,7 @@ export enum ProductAiLanguage { export enum ProductAiFillField { summary = 'summary', description = 'description', + nameEn = 'nameEn', } export class CreateProductByAiDto { @@ -27,10 +28,15 @@ export class FillProductFieldByAiDto { @IsEnum(ProductAiFillField) field!: ProductAiFillField; - /** Editable user prompt shown in the dashboard modal. */ + /** Editable user prompt shown in the dashboard modal (not used for nameEn). */ + @ValidateIf( + (o: FillProductFieldByAiDto) => + o.field === ProductAiFillField.summary || + o.field === ProductAiFillField.description, + ) @IsString() @MinLength(10) - prompt!: string; + prompt?: string; @IsOptional() @IsString() diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts index 4ffa88d..feb7abd 100644 --- a/src/products/product-ai.service.ts +++ b/src/products/product-ai.service.ts @@ -141,9 +141,60 @@ export class ProductAiService { } } + if (dto.field === ProductAiFillField.nameEn) { + if (!nameFa) { + throw new BadRequestException( + 'Enter a Persian product name before translating to English', + ); + } + + const provider = resolveAiProvider(this.config); + const systemPrompt = `You translate Iranian e-commerce product names from Persian (Farsi) to English. +Return ONLY valid JSON: { "text": "..." } +Use natural English product naming suitable for a catalog title. +Keep brand names, model numbers, and units unchanged. +Do not add marketing fluff or extra words beyond a faithful translation.`; + + const userPrompt = JSON.stringify({ + nameFa, + category: categoryName || null, + }); + + let content: string; + try { + content = await requestAiJsonCompletion( + provider, + systemPrompt, + userPrompt, + 0.2, + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'AI request failed'; + throw new BadRequestException(message); + } + + let parsed: { text?: unknown }; + try { + parsed = JSON.parse(content) as { text?: unknown }; + } catch { + throw new BadRequestException('AI returned invalid JSON'); + } + + const text = String(parsed.text ?? '').trim(); + if (!text) { + throw new BadRequestException('AI returned an empty English name'); + } + + return { field: ProductAiFillField.nameEn, text }; + } + const provider = resolveAiProvider(this.config); const isSummary = dto.field === ProductAiFillField.summary; + if (!dto.prompt?.trim()) { + throw new BadRequestException('Prompt is required for this field'); + } + const systemPrompt = isSummary ? `You write short e-commerce product listing summaries for Iranian stores. Return ONLY valid JSON: { "text": "..." } @@ -162,7 +213,7 @@ Never write sales CTAs, discounts, or vague marketing slogans. Aim for 2–4 short paragraphs and optionally a bullet list.`; const userPrompt = JSON.stringify({ - instructions: dto.prompt.trim(), + instructions: dto.prompt!.trim(), product: { title: title || null, nameFa: nameFa || null, diff --git a/src/website-analytics/dto/view-activity-query.dto.ts b/src/website-analytics/dto/view-activity-query.dto.ts index ecefea9..5188955 100644 --- a/src/website-analytics/dto/view-activity-query.dto.ts +++ b/src/website-analytics/dto/view-activity-query.dto.ts @@ -4,6 +4,7 @@ import { WEBSITE_PAGE_KINDS } from '../website-analytics.types'; const ACTIVITY_KINDS = [ ...WEBSITE_PAGE_KINDS, + 'all', 'website', 'product', 'portfolio', diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts index 88aff02..5d228e5 100644 --- a/src/website-analytics/website-analytics.service.ts +++ b/src/website-analytics/website-analytics.service.ts @@ -92,26 +92,40 @@ export class WebsiteAnalyticsService { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'business.read'); - const kind = normalizeWebsitePageKind(kindRaw); - if (!kind) { - throw new BadRequestException(`Invalid view kind: ${kindRaw}`); - } - const days = Math.min(Math.max(daysRaw ?? 30, 1), 90); const from = startOfLocalDay(days - 1); - const rows = await this.prisma.$queryRaw<{ day: Date; count: number }[]>( - Prisma.sql` - SELECT viewed_at::date AS day, - COUNT(*)::int AS count - FROM website_page_views - WHERE business_id = ${businessId} - AND page_kind = ${kind}::website_page_kind - AND viewed_at >= ${from} - GROUP BY 1 - ORDER BY 1 - `, - ); + const aggregateAll = kindRaw.trim() === 'all'; + + const kind = aggregateAll ? null : normalizeWebsitePageKind(kindRaw); + if (!aggregateAll && !kind) { + throw new BadRequestException(`Invalid view kind: ${kindRaw}`); + } + + const rows = aggregateAll + ? await this.prisma.$queryRaw<{ day: Date; count: number }[]>( + Prisma.sql` + SELECT viewed_at::date AS day, + COUNT(*)::int AS count + FROM website_page_views + WHERE business_id = ${businessId} + AND viewed_at >= ${from} + GROUP BY 1 + ORDER BY 1 + `, + ) + : await this.prisma.$queryRaw<{ day: Date; count: number }[]>( + Prisma.sql` + SELECT viewed_at::date AS day, + COUNT(*)::int AS count + FROM website_page_views + WHERE business_id = ${businessId} + AND page_kind = ${kind}::website_page_kind + AND viewed_at >= ${from} + GROUP BY 1 + ORDER BY 1 + `, + ); const primary = buildDailyActivitySeries(days, rows); return { diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts index f306e03..4fecdcc 100644 --- a/src/website-analytics/website-analytics.types.ts +++ b/src/website-analytics/website-analytics.types.ts @@ -50,9 +50,9 @@ export function pageKindRequiresEntity(kind: WebsitePageKind): boolean { return DETAIL_KINDS.has(kind); } -/** Maps home dashboard chart ids to analytics page kinds. */ -export const HOME_CHART_VIEW_KIND: Partial> = { - website_views_30d: 'home', +/** Maps home dashboard chart ids to analytics page kinds (`all` = every storefront page). */ +export const HOME_CHART_VIEW_KIND: Partial> = { + website_views_30d: 'all', product_views_30d: 'product_detail', portfolio_views_30d: 'portfolio_detail', blog_views_30d: 'blog_detail', diff --git a/src/website/website-static-images.controller.ts b/src/website/website-static-images.controller.ts index be69c88..d107490 100644 --- a/src/website/website-static-images.controller.ts +++ b/src/website/website-static-images.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { CreateWebsiteStaticImageSlotDto, ListWebsiteStaticImageSlotsDto, @@ -24,14 +25,22 @@ import { WebsiteStaticImagesService } from './website-static-images.service'; @Controller('tenants/:host/website/static-images') export class PublicWebsiteStaticImagesController { - constructor(private readonly service: WebsiteStaticImagesService) {} + constructor( + private readonly service: WebsiteStaticImagesService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list( + async list( @Param('host') host: string, @Query() query: PublicWebsiteStaticImagesQueryDto, ) { - return this.service.listPublic(host, query.pageKey); + const result = await this.service.listPublic(host, query.pageKey); + const pageKey = query.pageKey?.trim() || 'home'; + if (pageKey === 'home') { + this.analytics.trackPublicPage(host, 'home'); + } + return result; } @Get(':key')