From d68ef2d08ac61a75670d056b9098fca746ec24bb Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 11 Sep 2026 09:36:06 +0330 Subject: [PATCH] Add business profile AI fill endpoint for abstract and description. Co-authored-by: Cursor --- .../business-profile-ai.service.ts | 99 +++++++++++++++++++ .../business-profile.controller.ts | 19 +++- .../business-profile.module.ts | 3 +- .../dto/business-profile-ai.dto.ts | 27 +++++ 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/business-profile/business-profile-ai.service.ts create mode 100644 src/business-profile/dto/business-profile-ai.dto.ts diff --git a/src/business-profile/business-profile-ai.service.ts b/src/business-profile/business-profile-ai.service.ts new file mode 100644 index 0000000..13e03bb --- /dev/null +++ b/src/business-profile/business-profile-ai.service.ts @@ -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 }; + } +} diff --git a/src/business-profile/business-profile.controller.ts b/src/business-profile/business-profile.controller.ts index 7f280e9..5539d84 100644 --- a/src/business-profile/business-profile.controller.ts +++ b/src/business-profile/business-profile.controller.ts @@ -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); + } } diff --git a/src/business-profile/business-profile.module.ts b/src/business-profile/business-profile.module.ts index e366787..f6da55f 100644 --- a/src/business-profile/business-profile.module.ts +++ b/src/business-profile/business-profile.module.ts @@ -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 {} diff --git a/src/business-profile/dto/business-profile-ai.dto.ts b/src/business-profile/dto/business-profile-ai.dto.ts new file mode 100644 index 0000000..735dd51 --- /dev/null +++ b/src/business-profile/dto/business-profile-ai.dto.ts @@ -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; +}