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:
co-authored by
Cursor
parent
0d9c2fd8ea
commit
0c1c3f7ff9
@@ -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()
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user