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 {
|
export enum ProductAiLanguage {
|
||||||
en = 'en',
|
en = 'en',
|
||||||
fa = 'fa',
|
fa = 'fa',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProductAiFillField {
|
||||||
|
summary = 'summary',
|
||||||
|
description = 'description',
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateProductByAiDto {
|
export class CreateProductByAiDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@@ -17,3 +22,25 @@ export class CreateProductByAiDto {
|
|||||||
@IsEnum(ProductAiLanguage)
|
@IsEnum(ProductAiLanguage)
|
||||||
language!: 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 { AuthUser } from '../auth/auth.types';
|
||||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import {
|
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: {
|
private async generateDraft(input: {
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
productName: 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 { 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 { CreateProductByAiDto } from './dto/product-ai.dto';
|
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
|
||||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||||
@@ -54,6 +54,16 @@ export class ProductsController {
|
|||||||
return this.aiService.createFromAi(businessId, dto, user);
|
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')
|
@Get(':productId')
|
||||||
@RequireBusinessPermission('products.read')
|
@RequireBusinessPermission('products.read')
|
||||||
getOne(
|
getOne(
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export class S3StorageDriver {
|
|||||||
Key: key,
|
Key: key,
|
||||||
Body: input.body,
|
Body: input.body,
|
||||||
ContentType: input.contentType,
|
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