diff --git a/src/products/dto/product-ai.dto.ts b/src/products/dto/product-ai.dto.ts index c3981d1..97cc6da 100644 --- a/src/products/dto/product-ai.dto.ts +++ b/src/products/dto/product-ai.dto.ts @@ -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; +} diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts index a62cdc0..3e5e8ca 100644 --- a/src/products/product-ai.service.ts +++ b/src/products/product-ai.service.ts @@ -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 1–3 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 2–4 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; diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 5213e49..262205c 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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( diff --git a/src/storage/s3-storage.driver.ts b/src/storage/s3-storage.driver.ts index fa08447..1101f6f 100644 --- a/src/storage/s3-storage.driver.ts +++ b/src/storage/s3-storage.driver.ts @@ -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', }), );