diff --git a/src/products/color-entries.spec.ts b/src/products/color-entries.spec.ts index 8022574..f6c3879 100644 --- a/src/products/color-entries.spec.ts +++ b/src/products/color-entries.spec.ts @@ -38,6 +38,28 @@ describe('product image separation', () => { ]); }); + it('recovers color name from product.color when validation stripped nested fields', () => { + const db = serializeColorsJsonForDb( + [{ imageUrl: 'data:image/jpeg;base64,xyz' }], + 'قرمز شرابی', + ); + expect(db).toEqual([ + { + name: 'قرمز شرابی', + imageUrl: 'data:image/jpeg;base64,xyz', + imageUrls: ['data:image/jpeg;base64,xyz'], + }, + ]); + const entries = parseColorEntries(db, 'قرمز شرابی'); + expect(entries[0]?.imageUrls).toEqual(['data:image/jpeg;base64,xyz']); + expect(entries.map((e) => e.name)).toEqual(['قرمز شرابی']); + }); + + it('falls back to product.color when colorsJson rows are empty objects', () => { + const entries = parseColorEntries([{}], 'قرمز شرابی'); + expect(entries).toEqual([{ name: 'قرمز شرابی', imageUrls: [] }]); + }); + it('does not merge color images into general list when only imageUrlsJson is set', () => { const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]); expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([ diff --git a/src/products/color-entries.ts b/src/products/color-entries.ts index ee75f4d..8f4d86b 100644 --- a/src/products/color-entries.ts +++ b/src/products/color-entries.ts @@ -28,8 +28,8 @@ export function parseColorEntries( return name ? [{ name, imageUrls: [] }] : []; } - return colorsJson - .map((item): ProductColorEntry | null => { + const parsed = colorsJson + .map((item, index): ProductColorEntry | null => { if (typeof item === 'string') { const name = item.trim(); return name ? { name, imageUrls: [] } : null; @@ -40,8 +40,7 @@ export function parseColorEntries( imageUrl?: unknown; imageUrls?: unknown; }; - const name = String(raw.name ?? '').trim(); - if (!name) return null; + let name = String(raw.name ?? '').trim(); const fromArray = normalizeUrlList(raw.imageUrls); const legacy = typeof raw.imageUrl === 'string' && raw.imageUrl.trim() @@ -55,12 +54,21 @@ export function parseColorEntries( : legacy ? [legacy] : []; + if (!name && imageUrls.length > 0) { + name = fallbackColor?.trim() || `رنگ ${index + 1}`; + } + if (!name) return null; const imageUrl = imageUrls[0]; return { name, imageUrl, imageUrls }; } return null; }) .filter((entry): entry is ProductColorEntry => Boolean(entry)); + + if (parsed.length > 0) return parsed; + + const fallbackName = fallbackColor?.trim(); + return fallbackName ? [{ name: fallbackName, imageUrls: [] }] : []; } /** تصاویر عمومی محصول — فقط از imageUrlsJson، بدون merge با رنگ‌ها */ diff --git a/src/products/dto/product-color-json.dto.ts b/src/products/dto/product-color-json.dto.ts index 1961161..55ddd4d 100644 --- a/src/products/dto/product-color-json.dto.ts +++ b/src/products/dto/product-color-json.dto.ts @@ -50,22 +50,65 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] | return out; } -/** ذخیره در Prisma — imageUrl + imageUrls برای هر رنگ */ -export function serializeColorsJsonForDb(raw: unknown): unknown[] | undefined { - const entries = transformColorsJson(raw); - if (entries === undefined) return undefined; - return entries.map((entry) => { - const merged: string[] = []; - const push = (u?: string) => { - const t = u?.trim(); - if (t && !merged.includes(t)) merged.push(t); - }; - for (const u of entry.imageUrls ?? []) push(u); - push(entry.imageUrl); +function mergeColorImageUrls( + imageUrls?: string[], + imageUrl?: string, +): string[] { + const merged: string[] = []; + const push = (u?: string) => { + const t = u?.trim(); + if (t && !merged.includes(t)) merged.push(t); + }; + for (const u of imageUrls ?? []) push(u); + push(imageUrl); + return merged; +} + +/** ذخیره در Prisma — imageUrl + imageUrls برای هر رنگ (از body خام یا DTO) */ +export function serializeColorsJsonForDb( + raw: unknown, + fallbackColor?: string, +): unknown[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw)) return undefined; + + const colorFallback = fallbackColor?.trim() || ''; + const out: Array<{ name: string; imageUrl?: string; imageUrls?: string[] }> = + []; + + for (let i = 0; i < raw.length; i++) { + const item = raw[i]; + if (typeof item === 'string') { + const name = item.trim(); + if (name) out.push({ name, imageUrls: [] }); + continue; + } + if (!item || typeof item !== 'object') continue; + + const o = item as Record; + let name = String(o.name ?? '').trim(); + const imageUrls = Array.isArray(o.imageUrls) + ? o.imageUrls + .filter((u): u is string => typeof u === 'string' && u.trim().length > 0) + .map((u) => u.trim()) + : undefined; + const imageUrl = + typeof o.imageUrl === 'string' && o.imageUrl.trim() + ? o.imageUrl.trim() + : undefined; + const merged = mergeColorImageUrls(imageUrls, imageUrl); + + if (!name && merged.length > 0) { + name = colorFallback || `رنگ ${i + 1}`; + } + if (!name) continue; + const first = merged[0]; - return { - name: entry.name, + out.push({ + name, ...(first ? { imageUrl: first, imageUrls: merged } : {}), - }; - }); + }); + } + + return out; } diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 893f069..0991f66 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -76,13 +76,41 @@ export class ProductsService { return false; } + private colorsJsonHasPayload(raw: unknown): boolean { + if (!Array.isArray(raw) || raw.length === 0) return false; + return raw.some((item) => { + if (typeof item === 'string') return item.trim().length > 0; + if (!item || typeof item !== 'object') return false; + const o = item as Record; + if (String(o.name ?? '').trim()) return true; + if (typeof o.imageUrl === 'string' && o.imageUrl.trim()) return true; + return ( + Array.isArray(o.imageUrls) && + o.imageUrls.some((u) => typeof u === 'string' && u.trim().length > 0) + ); + }); + } + + private colorsJsonSourceForWrite( + preserved: unknown | undefined, + fromDto: unknown | undefined, + ): unknown | undefined { + if (preserved !== undefined && this.colorsJsonHasPayload(preserved)) { + return preserved; + } + if (fromDto !== undefined) return fromDto; + if (preserved !== undefined) return preserved; + return undefined; + } + private colorsJsonForWrite( preserved: unknown | undefined, fromDto: unknown | undefined, + fallbackColor?: string, ): Prisma.InputJsonValue | undefined { - const source = preserved !== undefined ? preserved : fromDto; + const source = this.colorsJsonSourceForWrite(preserved, fromDto); if (source === undefined) return undefined; - const serialized = serializeColorsJsonForDb(source); + const serialized = serializeColorsJsonForDb(source, fallbackColor); if (serialized === undefined) return undefined; return serialized as Prisma.InputJsonValue; } @@ -118,6 +146,7 @@ export class ProductsService { colorsJson: this.colorsJsonForWrite( preservedColorsJson, dto.colorsJson, + dto.color, ), imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined, tagsJson: dto.tagsJson ?? undefined, @@ -204,7 +233,11 @@ export class ProductsService { colorsJson: dto.colorsJson === undefined && preservedColorsJson === undefined ? undefined - : this.colorsJsonForWrite(preservedColorsJson, dto.colorsJson), + : this.colorsJsonForWrite( + preservedColorsJson, + dto.colorsJson, + dto.color, + ), imageUrlsJson: dto.imageUrlsJson === undefined ? undefined