Add product field AI fill and long-lived media cache headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-08 16:35:14 +03:30
co-authored by Cursor
parent b5a54e4174
commit 77f3cb2a67
4 changed files with 127 additions and 3 deletions
+28 -1
View File
@@ -1,10 +1,15 @@
import { IsEnum, IsString, MinLength } from 'class-validator';
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
export enum ProductAiLanguage {
en = 'en',
fa = 'fa',
}
export enum ProductAiFillField {
summary = 'summary',
description = 'description',
}
export class CreateProductByAiDto {
@IsString()
@MinLength(1)
@@ -17,3 +22,25 @@ export class CreateProductByAiDto {
@IsEnum(ProductAiLanguage)
language!: ProductAiLanguage;
}
export class FillProductFieldByAiDto {
@IsEnum(ProductAiFillField)
field!: ProductAiFillField;
/** Editable user prompt shown in the dashboard modal. */
@IsString()
@MinLength(10)
prompt!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
categoryId?: string;
}
+86 -1
View File
@@ -7,7 +7,7 @@ import { ContentStatus, TechnicalFieldType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductByAiDto, ProductAiLanguage } from './dto/product-ai.dto';
import { CreateProductByAiDto, FillProductFieldByAiDto, ProductAiFillField, ProductAiLanguage } from './dto/product-ai.dto';
import { ProductTechnicalInfoService } from './product-technical-info.service';
import { ProductsService } from './products.service';
import {
@@ -112,6 +112,91 @@ export class ProductAiService {
};
}
async fillField(
businessIdRaw: string,
dto: FillProductFieldByAiDto,
_actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const title = dto.title?.trim() ?? '';
const nameFa = dto.nameFa?.trim() ?? '';
if (!title && !nameFa) {
throw new BadRequestException('Enter a product name before using AI fill');
}
let categoryName = '';
if (dto.categoryId?.trim()) {
const category = await this.prisma.category.findFirst({
where: {
id: BigInt(dto.categoryId),
businessId,
isActive: true,
},
select: { name: true, nameFa: true },
});
if (category) {
categoryName = category.nameFa?.trim() || category.name;
}
}
const provider = resolveAiProvider(this.config);
const isSummary = dto.field === ProductAiFillField.summary;
const systemPrompt = isSummary
? `You write short e-commerce product listing summaries for Iranian stores.
Return ONLY valid JSON: { "text": "..." }
Follow the user's instructions for language and tone.
Keep the summary to 13 sentences. No HTML. No markdown.
Prefer a technical, informative style: what the product is and why its traits matter.
Never write sales CTAs, discounts, "buy now", or vague praise like "excellent choice" / "amazing experience".`
: `You write e-commerce product descriptions as HTML for Iranian stores.
Return ONLY valid JSON: { "html": "..." }
Allowed tags only: p, ul, ol, li, strong, em, br.
Follow the user's instructions for language and tone.
Prefer a technical, informative structure: what it is, key traits/composition, typical use.
Never write sales CTAs, discounts, or vague marketing slogans.
Aim for 24 short paragraphs and optionally a bullet list. No scripts, styles, or markdown.`;
const userPrompt = JSON.stringify({
instructions: dto.prompt.trim(),
product: {
title: title || null,
nameFa: nameFa || null,
category: categoryName || null,
},
});
let content: string;
try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.5);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
}
let parsed: { text?: unknown; html?: unknown };
try {
parsed = JSON.parse(content) as { text?: unknown; html?: unknown };
} catch {
throw new BadRequestException('AI returned invalid JSON');
}
if (isSummary) {
const text = String(parsed.text ?? '').trim();
if (!text) {
throw new BadRequestException('AI returned an empty summary');
}
return { field: ProductAiFillField.summary, text };
}
const html = String(parsed.html ?? '').trim();
if (!html) {
throw new BadRequestException('AI returned an empty description');
}
return { field: ProductAiFillField.description, html };
}
private async generateDraft(input: {
categoryName: string;
productName: string;
+11 -1
View File
@@ -15,7 +15,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 { CreateProductByAiDto } from './dto/product-ai.dto';
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
@@ -54,6 +54,16 @@ export class ProductsController {
return this.aiService.createFromAi(businessId, dto, user);
}
@Post('ai-fill')
@RequireBusinessPermission('products.create')
fillFieldByAi(
@Param('businessId') businessId: string,
@Body() dto: FillProductFieldByAiDto,
@CurrentUser() user: AuthUser,
) {
return this.aiService.fillField(businessId, dto, user);
}
@Get(':productId')
@RequireBusinessPermission('products.read')
getOne(
+2
View File
@@ -46,6 +46,8 @@ export class S3StorageDriver {
Key: key,
Body: input.body,
ContentType: input.contentType,
// Stable public media URLs — let browsers/CDN cache aggressively.
CacheControl: 'public, max-age=31536000, immutable',
}),
);