Add business profile AI fill endpoint for abstract and description.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-11 09:36:06 +03:30
co-authored by Cursor
parent aaf7a83742
commit d68ef2d08a
4 changed files with 145 additions and 3 deletions
@@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthUser } from '../auth/auth.types';
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
import {
requestAiJsonCompletion,
resolveAiProvider,
} from '../common/ai-provider.util';
import {
BusinessProfileAiFillField,
FillBusinessProfileFieldByAiDto,
} from './dto/business-profile-ai.dto';
@Injectable()
export class BusinessProfileAiService {
constructor(private readonly config: ConfigService) {}
async fillField(
_businessIdRaw: string,
dto: FillBusinessProfileFieldByAiDto,
_actor: AuthUser,
) {
const prompt = dto.prompt?.trim();
if (!prompt || prompt.length < 10) {
throw new BadRequestException('Prompt is required');
}
const nameEn = dto.nameEn?.trim() || '';
const nameFa = dto.nameFa?.trim() || '';
if (!nameEn && !nameFa) {
throw new BadRequestException(
'Enter a business name before using AI fill',
);
}
const provider = resolveAiProvider(this.config);
const isAbout = dto.field === BusinessProfileAiFillField.about;
const systemPrompt = isAbout
? `You write short business abstracts for an Iranian B2B marketplace listing.
Return ONLY valid JSON: { "text": "..." }
Follow the user's instructions for language and tone.
Keep the abstract to 1–4 sentences. No HTML. No markdown.
Prefer a clear, professional, informative style about what the business does.
Never write sales CTAs, discounts, or vague praise.`
: `You write business profile descriptions as HTML for an advanced rich-text editor on an Iranian marketplace.
Return ONLY valid JSON: { "html": "..." }
The "html" value must be a raw HTML fragment (real tags, not escaped entities, not markdown, not code fences).
Allowed tags only: p, ul, ol, li, strong, em, br.
Do not wrap in html/body/div. Do not include scripts, styles, classes, or attributes.
Follow the user's instructions for language and tone.
Prefer a professional, informative structure about the company, offerings, and strengths.
Never write sales CTAs, discounts, or vague marketing slogans.
Aim for 2–5 short paragraphs and optionally a bullet list.`;
const userPrompt = JSON.stringify({
instructions: prompt,
business: {
nameEn: nameEn || null,
nameFa: nameFa || null,
categories: dto.categories?.trim() || 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 (isAbout) {
const text = String(parsed.text ?? '').trim();
if (!text) {
throw new BadRequestException('AI returned an empty abstract');
}
return { field: BusinessProfileAiFillField.about, text };
}
const html = normalizeAiDescriptionHtml(String(parsed.html ?? ''));
if (!html) {
throw new BadRequestException('AI returned an empty description');
}
return { field: BusinessProfileAiFillField.vision, html };
}
}
@@ -1,16 +1,21 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
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 { BusinessProfileAiService } from './business-profile-ai.service';
import { BusinessProfileService } from './business-profile.service';
import { FillBusinessProfileFieldByAiDto } from './dto/business-profile-ai.dto';
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
@Controller('businesses/:businessId/profile')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BusinessProfileController {
constructor(private readonly service: BusinessProfileService) {}
constructor(
private readonly service: BusinessProfileService,
private readonly aiService: BusinessProfileAiService,
) {}
@Get()
@RequireBusinessPermission('business.read')
@@ -27,4 +32,14 @@ export class BusinessProfileController {
) {
return this.service.update(businessId, dto, user);
}
@Post('ai-fill')
@RequireBusinessPermission('business.update')
fillFieldByAi(
@Param('businessId') businessId: string,
@Body() dto: FillBusinessProfileFieldByAiDto,
@CurrentUser() user: AuthUser,
) {
return this.aiService.fillField(businessId, dto, user);
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessProfileAiService } from './business-profile-ai.service';
import { BusinessProfileController } from './business-profile.controller';
import { BusinessProfileService } from './business-profile.service';
@Module({
imports: [AuthModule],
controllers: [BusinessProfileController],
providers: [BusinessProfileService],
providers: [BusinessProfileService, BusinessProfileAiService],
})
export class BusinessProfileModule {}
@@ -0,0 +1,27 @@
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
export enum BusinessProfileAiFillField {
about = 'about',
vision = 'vision',
}
export class FillBusinessProfileFieldByAiDto {
@IsEnum(BusinessProfileAiFillField)
field!: BusinessProfileAiFillField;
@IsString()
@MinLength(10)
prompt!: string;
@IsOptional()
@IsString()
nameEn?: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
categories?: string;
}