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 { export enum ProductAiLanguage {
en = 'en', en = 'en',
@@ -8,6 +8,7 @@ export enum ProductAiLanguage {
export enum ProductAiFillField { export enum ProductAiFillField {
summary = 'summary', summary = 'summary',
description = 'description', description = 'description',
nameEn = 'nameEn',
} }
export class CreateProductByAiDto { export class CreateProductByAiDto {
@@ -27,10 +28,15 @@ export class FillProductFieldByAiDto {
@IsEnum(ProductAiFillField) @IsEnum(ProductAiFillField)
field!: 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() @IsString()
@MinLength(10) @MinLength(10)
prompt!: string; prompt?: string;
@IsOptional() @IsOptional()
@IsString() @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 provider = resolveAiProvider(this.config);
const isSummary = dto.field === ProductAiFillField.summary; const isSummary = dto.field === ProductAiFillField.summary;
if (!dto.prompt?.trim()) {
throw new BadRequestException('Prompt is required for this field');
}
const systemPrompt = isSummary const systemPrompt = isSummary
? `You write short e-commerce product listing summaries for Iranian stores. ? `You write short e-commerce product listing summaries for Iranian stores.
Return ONLY valid JSON: { "text": "..." } 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.`; Aim for 2–4 short paragraphs and optionally a bullet list.`;
const userPrompt = JSON.stringify({ const userPrompt = JSON.stringify({
instructions: dto.prompt.trim(), instructions: dto.prompt!.trim(),
product: { product: {
title: title || null, title: title || null,
nameFa: nameFa || null, nameFa: nameFa || null,
@@ -4,6 +4,7 @@ import { WEBSITE_PAGE_KINDS } from '../website-analytics.types';
const ACTIVITY_KINDS = [ const ACTIVITY_KINDS = [
...WEBSITE_PAGE_KINDS, ...WEBSITE_PAGE_KINDS,
'all',
'website', 'website',
'product', 'product',
'portfolio', 'portfolio',
@@ -92,26 +92,40 @@ export class WebsiteAnalyticsService {
const businessId = BigInt(businessIdRaw); const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'business.read'); 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 days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const from = startOfLocalDay(days - 1); const from = startOfLocalDay(days - 1);
const rows = await this.prisma.$queryRaw<{ day: Date; count: number }[]>( const aggregateAll = kindRaw.trim() === 'all';
Prisma.sql`
SELECT viewed_at::date AS day, const kind = aggregateAll ? null : normalizeWebsitePageKind(kindRaw);
COUNT(*)::int AS count if (!aggregateAll && !kind) {
FROM website_page_views throw new BadRequestException(`Invalid view kind: ${kindRaw}`);
WHERE business_id = ${businessId} }
AND page_kind = ${kind}::website_page_kind
AND viewed_at >= ${from} const rows = aggregateAll
GROUP BY 1 ? await this.prisma.$queryRaw<{ day: Date; count: number }[]>(
ORDER BY 1 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); const primary = buildDailyActivitySeries(days, rows);
return { return {
@@ -50,9 +50,9 @@ export function pageKindRequiresEntity(kind: WebsitePageKind): boolean {
return DETAIL_KINDS.has(kind); return DETAIL_KINDS.has(kind);
} }
/** Maps home dashboard chart ids to analytics page kinds. */ /** Maps home dashboard chart ids to analytics page kinds (`all` = every storefront page). */
export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind>> = { export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind | 'all'>> = {
website_views_30d: 'home', website_views_30d: 'all',
product_views_30d: 'product_detail', product_views_30d: 'product_detail',
portfolio_views_30d: 'portfolio_detail', portfolio_views_30d: 'portfolio_detail',
blog_views_30d: 'blog_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 { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { import {
CreateWebsiteStaticImageSlotDto, CreateWebsiteStaticImageSlotDto,
ListWebsiteStaticImageSlotsDto, ListWebsiteStaticImageSlotsDto,
@@ -24,14 +25,22 @@ import { WebsiteStaticImagesService } from './website-static-images.service';
@Controller('tenants/:host/website/static-images') @Controller('tenants/:host/website/static-images')
export class PublicWebsiteStaticImagesController { export class PublicWebsiteStaticImagesController {
constructor(private readonly service: WebsiteStaticImagesService) {} constructor(
private readonly service: WebsiteStaticImagesService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get() @Get()
list( async list(
@Param('host') host: string, @Param('host') host: string,
@Query() query: PublicWebsiteStaticImagesQueryDto, @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') @Get(':key')