diff --git a/database/migrations/095_technical_field_type_multi_value.sql b/database/migrations/095_technical_field_type_multi_value.sql new file mode 100644 index 0000000..a255245 --- /dev/null +++ b/database/migrations/095_technical_field_type_multi_value.sql @@ -0,0 +1,6 @@ +-- Free-text list field type for category technical forms (duplicatable values). +DO $$ BEGIN + ALTER TYPE technical_field_type ADD VALUE 'multi_value'; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 362c292..604df24 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1660,6 +1660,7 @@ enum TechnicalFieldType { textarea select multi_select + multi_value @@map("technical_field_type") } diff --git a/src/categories/category-technical-form-ai.service.ts b/src/categories/category-technical-form-ai.service.ts index a3505af..8da253a 100644 --- a/src/categories/category-technical-form-ai.service.ts +++ b/src/categories/category-technical-form-ai.service.ts @@ -27,7 +27,22 @@ type AiTechnicalFormDraft = { }[]; }; -const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']); +const ALLOWED_TYPES = new Set([ + 'text', + 'textarea', + 'select', + 'multi_select', + 'multi_value', +]); + +const OTHER_FEATURES_LABEL = 'سایر ویژگی‌ها'; +const OTHER_FEATURES_LABELS = new Set([ + 'سایر ویژگی‌ها', + 'سایر ویژگی ها', + 'other features', + 'other feature', + 'additional features', +]); @Injectable() export class CategoryTechnicalFormAiService { @@ -96,34 +111,35 @@ export class CategoryTechnicalFormAiService { ); } - const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace. -Return ONLY valid JSON with this shape: + const systemPrompt = `تو طراح فرم مشخصات فنی برای دسته‌بندی‌های فروشگاهی در یک مارکت‌پلیس ایرانی هستی. +فقط JSON معتبر با این شکل برگردان: { "fields": [ { - "label": "Field label", - "type": "text | textarea | select | multi_select", + "label": "برچسب فیلد", + "type": "text | textarea | select | multi_select | multi_value", "isRequired": true, - "options": ["Option A", "Option B"] + "options": ["گزینه ۱", "گزینه ۲"] } ] } -Rules: -- Generate 5-12 practical fields that buyers need for products in the given category. -- Use a mix of text, textarea, select, and multi_select where appropriate. -- select and multi_select fields must include 3-8 realistic options. -- text fields are for short values (dimensions, weight, model year). -- textarea fields are for longer specs (features, care instructions). -- Mark important buyer-facing specs as required. -- Labels must be unique and human-readable. -- Write labels and option text in the same language as the user hint. If the hint is Persian (Farsi), use Persian labels and options; otherwise use English. -- Do not include price, stock, SKU, or warranty period fields. -- Prefer metric units common in Iran when relevant. -- When referencePages are provided, treat them as the source of truth: build fields that match the real specs / attributes shown on those pages (capacity, power, dimensions, materials, programs, colors, etc.). -- Prefer the main product specs near the product title; ignore conflicting related-product / upsell blocks. -- Do NOT invent field topics from memory when a reference page was provided — mirror what the page actually documents. -- Convert units carefully in labels when helpful (e.g. dimensions in cm if the page lists mm).`; +قوانین: +- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگی‌ها» را اضافه می‌کند). +- از ترکیب text، textarea، select، multi_select و multi_value استفاده کن. +- فیلدهای select و multi_select باید ۳ تا ۸ گزینه واقعی داشته باشند. +- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی. +- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل). +- textarea برای مشخصات طولانی‌تر (ویژگی‌ها، دستورالعمل نگهداری). +- مشخصات مهم خریدار را الزامی علامت بزن. +- برچسب‌ها باید یکتا و خوانا باشند. +- همیشه برچسب‌ها و گزینه‌ها را به فارسی بنویس. +- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز. +- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن. +- اگر referencePages داده شده، آن‌ها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات هم‌خوان باشد. +- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوک‌های محصول مرتبط/آپسل را نادیده بگیر. +- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد. +- یک فیلد «سایر ویژگی‌ها» خودت نساز؛ سیستم آن را به‌صورت multi_value اضافه می‌کند.`; const userPrompt = JSON.stringify({ category: input.categoryName, @@ -174,7 +190,8 @@ Rules: const label = String(raw.label ?? '').trim(); if (!label) continue; - const normalizedLabel = label.toLowerCase(); + const normalizedLabel = label.toLowerCase().replace(/\s+/g, ' ').trim(); + if (OTHER_FEATURES_LABELS.has(normalizedLabel)) continue; if (labels.has(normalizedLabel)) continue; labels.add(normalizedLabel); @@ -198,9 +215,15 @@ Rules: } fields.push(field); - if (fields.length >= 12) break; + if (fields.length >= 11) break; } + fields.push({ + label: OTHER_FEATURES_LABEL, + type: 'multi_value', + isRequired: false, + }); + if (!fields.length) { throw new BadRequestException('AI did not return any valid technical form fields'); } diff --git a/src/categories/dto/category-technical-form.dto.ts b/src/categories/dto/category-technical-form.dto.ts index 74d2cd7..09db7c5 100644 --- a/src/categories/dto/category-technical-form.dto.ts +++ b/src/categories/dto/category-technical-form.dto.ts @@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto { label!: string; @IsString() - @IsIn(['text', 'textarea', 'select', 'multi_select']) - type!: 'text' | 'textarea' | 'select' | 'multi_select'; + @IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value']) + type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value'; @IsOptional() @IsBoolean() diff --git a/src/common/technical-multi-value.util.ts b/src/common/technical-multi-value.util.ts new file mode 100644 index 0000000..ffe50ea --- /dev/null +++ b/src/common/technical-multi-value.util.ts @@ -0,0 +1,25 @@ +/** Encode free-text list values for storage in text_value columns. */ +export function encodeMultiValueText(values: string[]): string { + const cleaned = values.map((item) => String(item).trim()).filter(Boolean); + return JSON.stringify(cleaned); +} + +/** Decode free-text list values from text_value storage. */ +export function decodeMultiValueText(raw: string | null | undefined): string[] { + if (raw == null) return []; + const trimmed = raw.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return parsed.map((item) => String(item).trim()).filter(Boolean); + } + } catch { + // fall through + } + } + + return [trimmed]; +} diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts index d96a815..02af906 100644 --- a/src/products/product-ai.service.ts +++ b/src/products/product-ai.service.ts @@ -336,6 +336,8 @@ Rules: - Use exact field keys from the provided technicalFields list. - For select fields use one allowed option "value" exactly (not the label). - For multi_select fields use an array of allowed option values. +- For multi_value fields use an array of free-text strings. +- If a multi_value field is labeled «سایر ویژگی‌ها» (or similar “other features”), fill it with any remaining real product attributes/features that do not fit the other dedicated fields — one short string per leftover item (e.g. extra capabilities, included accessories, standards, certifications, special notes from the product page). Do not duplicate values already placed in other fields. - For text/textarea fields use plain strings in the same language as the field labels / user prompt (usually Persian). - When referencePages are provided, treat them as the source of truth. Copy specs from that page text (capacity, power, dimensions, weight, materials, programs, etc.). - Convert units carefully (e.g. mm → cm for dimension fields labeled in cm: 279x300x464 mm → 27.9x30x46.4 cm). @@ -415,6 +417,15 @@ Rules: continue; } + if (field.type === 'multi_value') { + const values = Array.isArray(raw) ? raw : [String(raw)]; + const filtered = values.map((value) => String(value).trim()).filter(Boolean); + if (filtered.length > 0) { + technicalValues[field.key] = filtered; + } + continue; + } + if (field.type === 'select') { const value = String(raw).trim(); const allowed = field.options.some((option) => option.value === value); @@ -476,6 +487,8 @@ Rules: - If technical form fields are provided, fill technicalValues using exact field keys. - For select fields use one allowed option value exactly. - For multi_select fields use an array of allowed option values. +- For multi_value fields use an array of free-text strings. +- If a multi_value field is labeled «سایر ویژگی‌ها» (or similar “other features”), put any leftover real product attributes there that do not belong in the other fields — one short string per item; do not repeat values already used elsewhere. - For text/textarea fields use plain strings. - Prefer the input language for summary/description tone, but always return both title and nameFa. - descriptionHtml should be 2-4 paragraphs with key specs. diff --git a/src/products/product-technical-info.service.ts b/src/products/product-technical-info.service.ts index 8e0aad8..0ac3d60 100644 --- a/src/products/product-technical-info.service.ts +++ b/src/products/product-technical-info.service.ts @@ -10,6 +10,10 @@ import { PermissionsService } from '../auth/permissions.service'; import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; import { PrismaService } from '../prisma/prisma.service'; import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto'; +import { + decodeMultiValueText, + encodeMultiValueText, +} from '../common/technical-multi-value.util'; type FormField = { id: string; @@ -159,6 +163,22 @@ export class ProductTechnicalInfoService { continue; } + if (field.type === 'multi_value') { + const items = Array.isArray(entry.value) + ? entry.value.map((item) => String(item).trim()).filter(Boolean) + : []; + if (!items.length) continue; + await tx.productTechnicalFieldValue.create({ + data: { + businessId, + productId: product.id, + fieldId, + textValue: encodeMultiValueText(items), + }, + }); + continue; + } + if (field.type === 'text' || field.type === 'textarea') { await tx.productTechnicalFieldValue.create({ data: { @@ -185,6 +205,9 @@ export class ProductTechnicalInfoService { } const selectedValues = entry.value as string[]; + if (!Array.isArray(selectedValues) || !selectedValues.length) { + continue; + } const fieldValue = await tx.productTechnicalFieldValue.create({ data: { businessId, @@ -230,6 +253,24 @@ export class ProductTechnicalInfoService { return; } + if (field.type === 'multi_value') { + if (!Array.isArray(value)) { + throw new BadRequestException( + `Field "${field.label}" expects an array of text values`, + ); + } + const items = value.map((item) => String(item).trim()).filter(Boolean); + if (!items.length) { + if (field.isRequired) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + return; + } + if (field.type === 'select') { if (typeof value !== 'string') { throw new BadRequestException( @@ -303,7 +344,17 @@ export class ProductTechnicalInfoService { fieldKey: field.key, fieldLabel: field.label, type: field.type, - value: field.type === 'multi_select' ? [] : null, + value: + field.type === 'multi_select' || field.type === 'multi_value' ? [] : null, + }; + } + + if (field.type === 'multi_value') { + return { + fieldKey: field.key, + fieldLabel: field.label, + type: field.type, + value: decodeMultiValueText(record.textValue), }; } diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts index 1449e4f..09af978 100644 --- a/src/user-products/user-products.service.ts +++ b/src/user-products/user-products.service.ts @@ -19,6 +19,10 @@ import { normalizeSeoMetaInput, readSeoMetaFromMetadata, } from '../common/seo-meta.util'; +import { + decodeMultiValueText, + encodeMultiValueText, +} from '../common/technical-multi-value.util'; import { MediaService } from '../media/media.service'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -1162,6 +1166,21 @@ export class UserProductsService { return; } + if (field.type === 'multi_value') { + if (entry.optionId || entry.optionIds?.length) { + throw new BadRequestException( + `Field "${field.label}" expects a text list value`, + ); + } + const items = decodeMultiValueText(entry.textValue); + if (field.isRequired && !items.length) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + if (field.type === 'select') { if (entry.textValue || entry.optionIds?.length) { throw new BadRequestException( @@ -1206,6 +1225,9 @@ export class UserProductsService { if (field.type === 'text' || field.type === 'textarea') { return Boolean(entry.textValue?.trim()); } + if (field.type === 'multi_value') { + return decodeMultiValueText(entry.textValue).length > 0; + } if (field.type === 'select') { return Boolean(entry.optionId); } @@ -1255,6 +1277,20 @@ export class UserProductsService { continue; } + if (field.type === 'multi_value') { + const items = decodeMultiValueText(entry.textValue); + if (!items.length) continue; + await tx.userProductTechnicalFieldValue.create({ + data: { + businessId, + userProductId, + fieldId, + textValue: encodeMultiValueText(items), + }, + }); + continue; + } + if (field.type === 'select') { await tx.userProductTechnicalFieldValue.create({ data: {