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 {
|
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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user