Files
descrybe/apps/api/internal/catalog/raw_v1.go
T
2026-08-16 12:00:28 +02:00

381 lines
11 KiB
Go

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 imgs, ok := merged["images"].([]any); ok && len(imgs) > 0 {
urls := coerceToURLList(imgs)
if main == "" && len(urls) > 0 {
main = urls[0]
urls = urls[1:]
} else if len(urls) > 0 && urls[0] == main {
urls = urls[1:]
}
for _, u := range urls {
if u != main && !containsString(more, u) {
more = append(more, u)
}
}
}
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"} {
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
}