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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-28 20:39:37 +03:30
co-authored by Cursor
parent 0d9c2fd8ea
commit 0c1c3f7ff9
6 changed files with 108 additions and 27 deletions
+9 -3
View File
@@ -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()
+52 -1
View File
@@ -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,
@@ -4,6 +4,7 @@ import { WEBSITE_PAGE_KINDS } from '../website-analytics.types';
const ACTIVITY_KINDS = [
...WEBSITE_PAGE_KINDS,
'all',
'website',
'product',
'portfolio',
@@ -92,15 +92,29 @@ 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 }[]>(
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
@@ -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<Record<string, WebsitePageKind>> = {
website_views_30d: 'home',
/** Maps home dashboard chart ids to analytics page kinds (`all` = every storefront page). */
export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind | 'all'>> = {
website_views_30d: 'all',
product_views_30d: 'product_detail',
portfolio_views_30d: 'portfolio_detail',
blog_views_30d: 'blog_detail',
@@ -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')