mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Add product field AI fill and long-lived media cache headers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b5a54e4174
commit
77f3cb2a67
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user