package catalog import ( "context" "encoding/json" "fmt" "regexp" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // V1ProcessItem is the legacy public-API product payload (POST /products/process items[]). type V1ProcessItem struct { EAN string `json:"ean"` CategoryUniqueID string `json:"category_unique_id,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Specifications []map[string]any `json:"specifications,omitempty"` Search string `json:"search,omitempty"` MainImage string `json:"main_image,omitempty"` MoreImages any `json:"more_images,omitempty"` MainImageCamel string `json:"mainImage,omitempty"` MoreImagesCamel any `json:"moreImages,omitempty"` ImageURL string `json:"image_url,omitempty"` AdditionalImageURLs any `json:"additional_image_urls,omitempty"` ImageLink string `json:"image_link,omitempty"` AdditionalImageLink any `json:"additional_image_link,omitempty"` } var nonDigit = regexp.MustCompile(`[^0-9]`) // NormalizeGTIN keeps digits when present; otherwise returns the trimmed original. func NormalizeGTIN(ean string) string { trimmed := strings.TrimSpace(ean) digits := nonDigit.ReplaceAllString(trimmed, "") if digits != "" { return digits } return trimmed } // BuildMappedDataFromV1Item mirrors legacy buildMappedDataFromItem + image field storage. // Empty optional fields are omitted (not null) so jsonb || merges cannot wipe catalog titles. func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any { mapped := map[string]any{ "ean": item.EAN, } if item.Title != "" { mapped["title"] = item.Title mapped["name"] = item.Title } if item.Description != "" { mapped["description"] = item.Description } if len(item.Specifications) > 0 { mapped["specifications"] = item.Specifications } if item.Search != "" { mapped["search"] = item.Search } if item.CategoryUniqueID != "" { mapped["category"] = item.CategoryUniqueID mapped["category_unique_id"] = item.CategoryUniqueID } for k, v := range mappedImageFieldsFromV1Item(item) { mapped[k] = v } return mapped } // v1ItemHasContent reports whether the request carries feed/product fields beyond EAN. // EAN-only requests must resolve an existing catalog row (e.g. after admin clone). func v1ItemHasContent(item V1ProcessItem) bool { if strings.TrimSpace(item.Title) != "" || strings.TrimSpace(item.Description) != "" { return true } if strings.TrimSpace(item.CategoryUniqueID) != "" || strings.TrimSpace(item.Search) != "" { return true } if len(item.Specifications) > 0 { return true } return len(mappedImageFieldsFromV1Item(item)) > 0 } func mappedImageFieldsFromV1Item(item V1ProcessItem) map[string]any { source := map[string]any{} putIf := func(k, v string) { if strings.TrimSpace(v) != "" { source[k] = strings.TrimSpace(v) } } putIf("main_image", item.MainImage) putIf("mainImage", item.MainImageCamel) putIf("image_url", item.ImageURL) putIf("image_link", item.ImageLink) if item.MoreImages != nil { source["more_images"] = item.MoreImages } if item.MoreImagesCamel != nil { source["moreImages"] = item.MoreImagesCamel } if item.AdditionalImageURLs != nil { source["additional_image_urls"] = item.AdditionalImageURLs } if item.AdditionalImageLink != nil { source["additional_image_link"] = item.AdditionalImageLink } return MappedImageFieldsForStorage(source) } // MappedImageFieldsForStorage writes feed-compatible image keys onto mapped_data. func MappedImageFieldsForStorage(source map[string]any) map[string]any { main, more := ExtractProductImages(source, nil) out := map[string]any{} if main != "" { out["image_url"] = main out["main_image"] = main out["image_link"] = main } if len(more) > 0 { out["additional_image_urls"] = more if len(more) == 1 { out["additional_image_link"] = more[0] } images := make([]string, 0, 1+len(more)) if main != "" { images = append(images, main) } images = append(images, more...) out["images"] = images } else if main != "" { out["images"] = []string{main} } return out } // ExtractProductImages returns main_image + more_images from mapped/raw maps. func ExtractProductImages(mapped, raw map[string]any) (main string, more []string) { merged := map[string]any{} for k, v := range raw { merged[k] = v } for k, v := range mapped { merged[k] = v } mainKeys := []string{"image_url", "main_image", "image_link", "mainImage", "MainImage", "imageUrl", "imageLink", "ImageLink"} moreKeys := []string{"additional_image_urls", "additional_image_link", "more_images", "moreImages", "MoreImages", "moreimages", "additionalImageLink", "additionalImageUrls"} for _, k := range mainKeys { if u := coerceToURLString(merged[k]); u != "" { main = u break } } for _, k := range moreKeys { list := coerceToURLList(merged[k]) if len(list) > 0 { more = list break } } if urls := coerceToURLList(merged["images"]); len(urls) > 0 { if main == "" { main = urls[0] urls = urls[1:] } else if urls[0] == main { urls = urls[1:] } for _, u := range urls { if u != main && !containsString(more, u) { more = append(more, u) } } } // Feeds often leave main_image empty while moreimages/images hold URLs. if main == "" && len(more) > 0 { main = more[0] more = more[1:] } if main != "" { filtered := more[:0] for _, u := range more { if u != main { filtered = append(filtered, u) } } more = filtered } return main, more } func coerceToURLString(value any) string { switch v := value.(type) { case nil: return "" case string: trimmed := strings.TrimSpace(v) if trimmed == "" || trimmed == "[object Object]" { return "" } if strings.HasPrefix(trimmed, "//") { return "https:" + trimmed } if strings.HasPrefix(strings.ToLower(trimmed), "http://") || strings.HasPrefix(strings.ToLower(trimmed), "https://") { return trimmed } return "" case []any: for _, entry := range v { if u := coerceToURLString(entry); u != "" { return u } } return "" case map[string]any: for _, k := range []string{"#text", "__cdata", "@_href", "@_url", "href", "url", "src", "link", "image"} { if u := coerceToURLString(v[k]); u != "" { return u } } return "" default: return "" } } func coerceToURLList(value any) []string { switch v := value.(type) { case nil: return nil case string: trimmed := strings.TrimSpace(v) if trimmed == "" { return nil } if strings.Contains(trimmed, ",") { parts := strings.Split(trimmed, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if u := coerceToURLString(p); u != "" { out = append(out, u) } } return out } if u := coerceToURLString(trimmed); u != "" { return []string{u} } return nil case []any: out := make([]string, 0, len(v)) for _, entry := range v { if u := coerceToURLString(entry); u != "" { out = append(out, u) } } return out case []string: out := make([]string, 0, len(v)) for _, entry := range v { if u := coerceToURLString(entry); u != "" { out = append(out, u) } } return out default: if u := coerceToURLString(value); u != "" { return []string{u} } return nil } } func containsString(ss []string, want string) bool { for _, s := range ss { if s == want { return true } } return false } // EnsureRawResult is one resolved raw product from a legacy items[] entry. type EnsureRawResult struct { RawProductID uuid.UUID EAN string Created bool } // EnsureRawProductsFromV1Items finds or creates/updates raw_products by EAN for the company. // Returns successfully resolved IDs and per-item error messages (non-fatal for partial batches). // // EAN-only items reuse existing catalog mapped_data (e.g. after admin clone-from-company). // Creating a brand-new row requires at least one content field; otherwise callers get empty // "Product {ean}" stubs with no feed data. func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uuid.UUID, items []V1ProcessItem) (ids []uuid.UUID, results []EnsureRawResult, errs []string, err error) { if s == nil || s.Pool == nil { return nil, nil, nil, fmt.Errorf("catalog not configured") } ids = make([]uuid.UUID, 0, len(items)) results = make([]EnsureRawResult, 0, len(items)) for _, item := range items { if strings.TrimSpace(item.EAN) == "" { errs = append(errs, "All items must have a valid 'ean' field") continue } gtin := NormalizeGTIN(item.EAN) mapped := BuildMappedDataFromV1Item(item) mappedJSON, mErr := json.Marshal(mapped) if mErr != nil { errs = append(errs, fmt.Sprintf("Error processing item with EAN %s: %v", item.EAN, mErr)) continue } var existingID uuid.UUID var existingMapped []byte qErr := s.Pool.QueryRow(ctx, ` SELECT id, mapped_data FROM raw_products WHERE company_id = $1 AND gtin = $2 ORDER BY updated_at DESC NULLS LAST LIMIT 1`, companyID, gtin).Scan(&existingID, &existingMapped) if qErr == nil { merged := map[string]any{} _ = json.Unmarshal(existingMapped, &merged) changed := false for k, v := range mapped { if v == nil { continue } if s, ok := v.(string); ok && strings.TrimSpace(s) == "" { continue } merged[k] = v changed = true } if item.CategoryUniqueID != "" { merged["category"] = item.CategoryUniqueID merged["category_unique_id"] = item.CategoryUniqueID changed = true } if changed { mergedJSON, _ := json.Marshal(merged) _, _ = s.Pool.Exec(ctx, ` UPDATE raw_products SET mapped_data = $3::jsonb, updated_at = now() WHERE id = $1 AND company_id = $2`, existingID, companyID, string(mergedJSON)) } ids = append(ids, existingID) results = append(results, EnsureRawResult{RawProductID: existingID, EAN: item.EAN, Created: false}) continue } if qErr != nil && qErr != pgx.ErrNoRows { errs = append(errs, fmt.Sprintf("Error processing item with EAN %s: %v", item.EAN, qErr)) continue } if !v1ItemHasContent(item) { errs = append(errs, fmt.Sprintf( "No catalog product for EAN %s. Use a GTIN from the cloned/synced feed, or include title/description/images in the request.", item.EAN, )) continue } var newID uuid.UUID insErr := s.Pool.QueryRow(ctx, ` INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed) VALUES ($1, $2, $3::jsonb, $3::jsonb, 'unprocessed', false) ON CONFLICT (company_id, gtin) DO UPDATE SET mapped_data = raw_products.mapped_data || EXCLUDED.mapped_data, updated_at = now() RETURNING id`, companyID, gtin, string(mappedJSON)).Scan(&newID) if insErr != nil { errs = append(errs, fmt.Sprintf("Failed to create raw product for EAN: %s", item.EAN)) continue } ids = append(ids, newID) results = append(results, EnsureRawResult{RawProductID: newID, EAN: item.EAN, Created: true}) } return ids, results, errs, nil }