Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const productCursorVersion = 1
|
||||
|
||||
// productCursor is an opaque keyset bookmark for product list pages.
|
||||
// Encoded as URL-safe base64 JSON in the `cursor` query param.
|
||||
type productCursor struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
SB string `json:"sb"`
|
||||
SO string `json:"so"`
|
||||
K string `json:"k"`
|
||||
Blank bool `json:"b,omitempty"`
|
||||
}
|
||||
|
||||
// HasProductCursor reports whether the filter requests keyset pagination.
|
||||
func HasProductCursor(f ListFilter) bool {
|
||||
return strings.TrimSpace(f.Cursor) != "" || strings.TrimSpace(f.AfterID) != ""
|
||||
}
|
||||
|
||||
// EncodeProductCursor builds an opaque cursor from a product list row.
|
||||
func EncodeProductCursor(f ListFilter, item map[string]any) (string, error) {
|
||||
f = NormalizeListFilter(f)
|
||||
id := stringifyID(item["id"])
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("missing id")
|
||||
}
|
||||
cur := productCursor{
|
||||
V: productCursorVersion,
|
||||
ID: id,
|
||||
SB: f.SortBy,
|
||||
SO: f.SortOrder,
|
||||
}
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
name := productSortName(item)
|
||||
cur.Blank = name == ""
|
||||
cur.K = strings.ToLower(name)
|
||||
case "createdAt":
|
||||
ts, ok := asTime(item["created_at"])
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing created_at")
|
||||
}
|
||||
cur.K = ts.UTC().Format(time.RFC3339Nano)
|
||||
default: // updatedAt
|
||||
ts, ok := asTime(item["updated_at"])
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing updated_at")
|
||||
}
|
||||
cur.K = ts.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// DecodeProductCursor parses an opaque product list cursor.
|
||||
func DecodeProductCursor(raw string) (productCursor, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return productCursor{}, fmt.Errorf("empty cursor")
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor encoding")
|
||||
}
|
||||
var cur productCursor
|
||||
if err := json.Unmarshal(b, &cur); err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor payload")
|
||||
}
|
||||
if cur.V != productCursorVersion {
|
||||
return productCursor{}, fmt.Errorf("unsupported cursor version")
|
||||
}
|
||||
if _, err := uuid.Parse(cur.ID); err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
switch cur.SB {
|
||||
case "name", "updatedAt", "createdAt":
|
||||
default:
|
||||
return productCursor{}, fmt.Errorf("invalid cursor sort")
|
||||
}
|
||||
if cur.SO != "asc" && cur.SO != "desc" {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor order")
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// NextProductCursor returns next_cursor / next_after_id when the page is full.
|
||||
// When the page length equals limit there may still be no further rows (rare);
|
||||
// clients should treat an empty follow-up page as the end.
|
||||
func NextProductCursor(f ListFilter, items []map[string]any, limit int) (nextCursor, nextAfterID string) {
|
||||
f = NormalizeListFilter(f)
|
||||
if limit <= 0 || len(items) < limit {
|
||||
return "", ""
|
||||
}
|
||||
last := items[len(items)-1]
|
||||
nextAfterID = stringifyID(last["id"])
|
||||
enc, err := EncodeProductCursor(f, last)
|
||||
if err != nil {
|
||||
return "", nextAfterID
|
||||
}
|
||||
return enc, nextAfterID
|
||||
}
|
||||
|
||||
func (c productCursor) matchesFilter(f ListFilter) bool {
|
||||
f = NormalizeListFilter(f)
|
||||
return c.SB == f.SortBy && c.SO == f.SortOrder
|
||||
}
|
||||
|
||||
// appendRawKeyset adds a keyset predicate for raw_products (alias rp).
|
||||
func appendRawKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
|
||||
id, err := uuid.Parse(cur.ID)
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
dirAfter := keysetOp(f.SortOrder)
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
nameExpr := `COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '')`
|
||||
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
|
||||
args = append(args, boolToInt(cur.Blank), cur.K, id)
|
||||
b, k, i := len(args)-2, len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf(`(
|
||||
%s > $%d
|
||||
OR (%s = $%d AND LOWER(%s) %s $%d)
|
||||
OR (%s = $%d AND LOWER(%s) = $%d AND rp.id %s $%d)
|
||||
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
|
||||
return args, where, nil
|
||||
case "createdAt":
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(rp.created_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
default: // updatedAt
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(rp.updated_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
}
|
||||
}
|
||||
|
||||
// appendProcessedKeyset adds a keyset predicate for processed_products (alias p).
|
||||
func appendProcessedKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
|
||||
id, err := uuid.Parse(cur.ID)
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
dirAfter := keysetOp(f.SortOrder)
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
nameExpr := `COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), '')`
|
||||
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
|
||||
args = append(args, boolToInt(cur.Blank), cur.K, id)
|
||||
b, k, i := len(args)-2, len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf(`(
|
||||
%s > $%d
|
||||
OR (%s = $%d AND LOWER(%s) %s $%d)
|
||||
OR (%s = $%d AND LOWER(%s) = $%d AND p.id %s $%d)
|
||||
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
|
||||
return args, where, nil
|
||||
case "createdAt":
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(p.created_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
default: // updatedAt
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(p.updated_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
}
|
||||
}
|
||||
|
||||
func keysetOp(sortOrder string) string {
|
||||
if sortOrder == "asc" {
|
||||
return ">"
|
||||
}
|
||||
return "<"
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productSortName(item map[string]any) string {
|
||||
for _, key := range []string{"processed_name", "name"} {
|
||||
if s := strings.TrimSpace(stringifyID(item[key])); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringifyID(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case uuid.UUID:
|
||||
return t.String()
|
||||
case [16]byte:
|
||||
return uuid.UUID(t).String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
func asTime(v any) (time.Time, bool) {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t, true
|
||||
case *time.Time:
|
||||
if t == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return *t, true
|
||||
case string:
|
||||
if ts, err := time.Parse(time.RFC3339Nano, t); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// resolveRawCursor decodes cursor or loads sort keys for after_id on raw_products.
|
||||
// missing=true means after_id was not found for this company (caller should return an empty page).
|
||||
func (s *Service) resolveRawCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
|
||||
if c := strings.TrimSpace(f.Cursor); c != "" {
|
||||
cur, err = DecodeProductCursor(c)
|
||||
if err != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid cursor")
|
||||
}
|
||||
if !cur.matchesFilter(f) {
|
||||
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
|
||||
}
|
||||
return cur, true, false, nil
|
||||
}
|
||||
after := strings.TrimSpace(f.AfterID)
|
||||
if after == "" {
|
||||
return productCursor{}, false, false, nil
|
||||
}
|
||||
id, parseErr := uuid.Parse(after)
|
||||
if parseErr != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid after_id")
|
||||
}
|
||||
var name string
|
||||
var createdAt, updatedAt time.Time
|
||||
scanErr := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(NULLIF(mapped_data->>'name', ''), NULLIF(mapped_data->>'title', ''), ''),
|
||||
created_at, updated_at
|
||||
FROM raw_products
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &createdAt, &updatedAt)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, pgx.ErrNoRows) {
|
||||
return productCursor{}, true, true, nil
|
||||
}
|
||||
return productCursor{}, false, false, scanErr
|
||||
}
|
||||
cur = productCursorFromRow(f, id.String(), name, createdAt, updatedAt)
|
||||
return cur, true, false, nil
|
||||
}
|
||||
|
||||
// resolveProcessedCursor decodes cursor or loads sort keys for after_id on processed_products.
|
||||
func (s *Service) resolveProcessedCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
|
||||
if c := strings.TrimSpace(f.Cursor); c != "" {
|
||||
cur, err = DecodeProductCursor(c)
|
||||
if err != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid cursor")
|
||||
}
|
||||
if !cur.matchesFilter(f) {
|
||||
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
|
||||
}
|
||||
return cur, true, false, nil
|
||||
}
|
||||
after := strings.TrimSpace(f.AfterID)
|
||||
if after == "" {
|
||||
return productCursor{}, false, false, nil
|
||||
}
|
||||
id, parseErr := uuid.Parse(after)
|
||||
if parseErr != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid after_id")
|
||||
}
|
||||
var name, processedName string
|
||||
var createdAt, updatedAt time.Time
|
||||
scanErr := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(name, ''), COALESCE(processed_name, ''), created_at, updated_at
|
||||
FROM processed_products
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &processedName, &createdAt, &updatedAt)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, pgx.ErrNoRows) {
|
||||
return productCursor{}, true, true, nil
|
||||
}
|
||||
return productCursor{}, false, false, scanErr
|
||||
}
|
||||
display := strings.TrimSpace(processedName)
|
||||
if display == "" {
|
||||
display = strings.TrimSpace(name)
|
||||
}
|
||||
cur = productCursorFromRow(f, id.String(), display, createdAt, updatedAt)
|
||||
return cur, true, false, nil
|
||||
}
|
||||
|
||||
func productCursorFromRow(f ListFilter, id, name string, createdAt, updatedAt time.Time) productCursor {
|
||||
cur := productCursor{
|
||||
V: productCursorVersion,
|
||||
ID: id,
|
||||
SB: f.SortBy,
|
||||
SO: f.SortOrder,
|
||||
}
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
cur.Blank = name == ""
|
||||
cur.K = strings.ToLower(name)
|
||||
case "createdAt":
|
||||
cur.K = createdAt.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
cur.K = updatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeProductCursorRoundTrip(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
|
||||
item := map[string]any{
|
||||
"id": "00000000-0000-4000-8000-000000000099",
|
||||
"updated_at": ts,
|
||||
}
|
||||
enc, err := EncodeProductCursor(f, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc == "" {
|
||||
t.Fatal("empty cursor")
|
||||
}
|
||||
cur, err := DecodeProductCursor(enc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur.ID != "00000000-0000-4000-8000-000000000099" {
|
||||
t.Fatalf("id=%q", cur.ID)
|
||||
}
|
||||
if cur.SB != "updatedAt" || cur.SO != "desc" {
|
||||
t.Fatalf("sort meta: %+v", cur)
|
||||
}
|
||||
if !strings.HasPrefix(cur.K, "2026-08-04T12:00:00") {
|
||||
t.Fatalf("key=%q", cur.K)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextProductCursor(t *testing.T) {
|
||||
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
|
||||
items := []map[string]any{
|
||||
{"id": "00000000-0000-4000-8000-000000000001", "updated_at": time.Now().UTC()},
|
||||
{"id": "00000000-0000-4000-8000-000000000002", "updated_at": time.Now().UTC()},
|
||||
}
|
||||
next, after := NextProductCursor(f, items, 2)
|
||||
if next == "" || after != "00000000-0000-4000-8000-000000000002" {
|
||||
t.Fatalf("next=%q after=%q", next, after)
|
||||
}
|
||||
none, noneAfter := NextProductCursor(f, items[:1], 2)
|
||||
if none != "" || noneAfter != "" {
|
||||
t.Fatalf("short page should end: next=%q after=%q", none, noneAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasProductCursorClearsOffset(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{Offset: 500, AfterID: "00000000-0000-4000-8000-000000000001"})
|
||||
if !HasProductCursor(f) {
|
||||
t.Fatal("expected cursor")
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("offset should clear with cursor: %d", f.Offset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ecommerceGroupDef struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
Order int
|
||||
}
|
||||
|
||||
type ecommerceFieldDef struct {
|
||||
Key string
|
||||
Name string
|
||||
Type string
|
||||
GroupKey string
|
||||
Required bool
|
||||
Enabled bool
|
||||
Recommended bool
|
||||
Unit string
|
||||
DefaultValue string
|
||||
SortOrder int
|
||||
Hints []string
|
||||
Description string
|
||||
}
|
||||
|
||||
func ecommerceGroups() []ecommerceGroupDef {
|
||||
return []ecommerceGroupDef{
|
||||
{Key: "basic", Name: "Basic Information", Description: "Core product identifiers and content", Order: 10},
|
||||
{Key: "pricing", Name: "Pricing", Description: "Price and currency fields", Order: 20},
|
||||
{Key: "media", Name: "Media", Description: "Images and media URLs", Order: 30},
|
||||
{Key: "taxonomy", Name: "Taxonomy", Description: "Categories and classification", Order: 40},
|
||||
{Key: "inventory", Name: "Inventory", Description: "Stock and availability", Order: 50},
|
||||
{Key: "attributes", Name: "Attributes", Description: "Variant and product attributes", Order: 60},
|
||||
{Key: "shipping", Name: "Shipping", Description: "Weight and dimensions", Order: 70},
|
||||
}
|
||||
}
|
||||
|
||||
func ecommerceFields() []ecommerceFieldDef {
|
||||
return []ecommerceFieldDef{
|
||||
{Key: "gtin", Name: "GTIN/EAN", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"ean", "upc", "barcode", "gtin13"}, Description: "Product barcode"},
|
||||
{Key: "title", Name: "Product name", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"name", "product_name", "product_title"}, Description: "Primary product title"},
|
||||
{Key: "brand", Name: "Brand", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 30, Hints: []string{"manufacturer", "vendor"}, Description: "Brand or manufacturer"},
|
||||
{Key: "description", Name: "Description", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 40, Hints: []string{"desc", "body", "long_description"}, Description: "Product description"},
|
||||
{Key: "sku", Name: "SKU", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 50, Hints: []string{"item_sku", "article_number"}, Description: "Stock keeping unit"},
|
||||
{Key: "mpn", Name: "MPN", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"manufacturer_part_number", "part_number"}, Description: "Manufacturer part number"},
|
||||
{Key: "product_model", Name: "Product model", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 65, Hints: []string{"model", "productmodel", "product_model"}, Description: "Manufacturer model name/number"},
|
||||
{Key: "product_url", Name: "Product URL", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"url", "link", "product_link"}, Description: "Canonical product page URL"},
|
||||
{Key: "official_link", Name: "Official link", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 80, Hints: []string{"officiallink", "manufacturer_url"}, Description: "Manufacturer or brand product page"},
|
||||
{Key: "price", Name: "Price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Unit: "EUR", Hints: []string{"regular_price", "list_price", "amount"}, Description: "Regular price"},
|
||||
{Key: "sale_price", Name: "Sale price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Unit: "EUR", Hints: []string{"special_price", "discount_price"}, Description: "Promotional price"},
|
||||
{Key: "purchase_price", Name: "Purchase price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 25, Unit: "EUR", Hints: []string{"purchaseprice", "cost", "buy_price"}, Description: "Cost / buy price"},
|
||||
{Key: "currency", Name: "Currency", Type: "string", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 30, DefaultValue: "EUR", Hints: []string{"price_currency", "curr"}, Description: "ISO currency code"},
|
||||
{Key: "image_url", Name: "Image URL", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"image", "image_link", "thumbnail"}, Description: "Primary product image"},
|
||||
{Key: "main_image", Name: "Main image", Type: "image", GroupKey: "media", Required: true, Enabled: true, Recommended: true, SortOrder: 15, Hints: []string{"mainimage", "image", "image_url"}, Description: "Main gallery image URL"},
|
||||
{Key: "additional_image_urls", Name: "Additional images", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"images", "gallery", "moreimages", "additional_images"}, Description: "Extra product images"},
|
||||
{Key: "video_url", Name: "Video URL", Type: "url", GroupKey: "media", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"videourl", "video"}, Description: "Product video URL"},
|
||||
{Key: "category", Name: "Category", Type: "string", GroupKey: "taxonomy", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"product_type", "google_product_category", "category_path"}, Description: "Product category"},
|
||||
{Key: "availability", Name: "Availability", Type: "string", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"in_stock", "stock_status", "stockstatus"}, Description: "Availability status"},
|
||||
{Key: "stock", Name: "Stock", Type: "number", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"quantity", "qty", "inventory"}, Description: "Stock quantity"},
|
||||
{Key: "color", Name: "Color", Type: "color", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"colour", "farbe"}, Description: "Color attribute"},
|
||||
{Key: "size", Name: "Size", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"groesse", "dimension_size"}, Description: "Size attribute"},
|
||||
{Key: "material", Name: "Material", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"fabric", "composition"}, Description: "Material attribute"},
|
||||
{Key: "warranty", Name: "Warranty", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"guarantee"}, Description: "Warranty term or text"},
|
||||
{Key: "service", Name: "Service", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{}, Description: "Service or support notes"},
|
||||
{Key: "specifications", Name: "Specifications", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"specs", "specification"}, Description: "Technical specifications"},
|
||||
{Key: "eprel_id", Name: "EPREL ID", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"eprelid", "eprel"}, Description: "EU energy label identifier"},
|
||||
{Key: "weight", Name: "Weight", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 10, Unit: "kg", Hints: []string{"shipping_weight", "product_weight", "netmass"}, Description: "Product weight"},
|
||||
{Key: "net_depth", Name: "Net depth", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 20, Hints: []string{"netdepth", "depth"}, Description: "Net depth"},
|
||||
{Key: "net_height", Name: "Net height", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"netheight", "height"}, Description: "Net height"},
|
||||
{Key: "net_width", Name: "Net width", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"netwidth", "width"}, Description: "Net width"},
|
||||
{Key: "net_mass", Name: "Net mass", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{"netmass", "mass"}, Description: "Net mass"},
|
||||
}
|
||||
}
|
||||
|
||||
func recommendedEcommerceKeys() []string {
|
||||
out := make([]string, 0)
|
||||
for _, f := range ecommerceFields() {
|
||||
if f.Recommended {
|
||||
out = append(out, f.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) ensureGroupID(ctx context.Context, companyID uuid.UUID, g ecommerceGroupDef) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM field_groups WHERE company_id = $1 AND name = $2 LIMIT 1`,
|
||||
companyID, g.Name).Scan(&id)
|
||||
if err == nil {
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE field_groups SET description = $3, "order" = $4, is_system = true, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID, g.Description, g.Order)
|
||||
return id, nil
|
||||
}
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO field_groups (company_id, name, description, "order", is_system)
|
||||
VALUES ($1, $2, $3, $4, true) RETURNING id`,
|
||||
companyID, g.Name, g.Description, g.Order).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// EnsureEcommerceCatalog upserts system field groups and standard fields for ecommerce.
|
||||
func (s *Service) EnsureEcommerceCatalog(ctx context.Context, companyID uuid.UUID) error {
|
||||
groupIDs := map[string]uuid.UUID{}
|
||||
for _, g := range ecommerceGroups() {
|
||||
id, err := s.ensureGroupID(ctx, companyID, g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs[g.Key] = id
|
||||
}
|
||||
|
||||
for _, f := range ecommerceFields() {
|
||||
gid, ok := groupIDs[f.GroupKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hints, _ := json.Marshal(f.Hints)
|
||||
var defVal *string
|
||||
if f.DefaultValue != "" {
|
||||
v := f.DefaultValue
|
||||
defVal = &v
|
||||
}
|
||||
var unit *string
|
||||
if f.Unit != "" {
|
||||
u := f.Unit
|
||||
unit = &u
|
||||
}
|
||||
var desc *string
|
||||
if f.Description != "" {
|
||||
d := f.Description
|
||||
desc = &d
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO standard_fields (
|
||||
company_id, name, key, type, group_id, is_required, description, default_value,
|
||||
is_system, enabled, unit, sort_order, mapping_hints
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true,$9,$10,$11,$12::jsonb)
|
||||
ON CONFLICT (company_id, key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
type = EXCLUDED.type,
|
||||
group_id = EXCLUDED.group_id,
|
||||
is_required = standard_fields.is_required OR EXCLUDED.is_required,
|
||||
description = COALESCE(EXCLUDED.description, standard_fields.description),
|
||||
default_value = COALESCE(standard_fields.default_value, EXCLUDED.default_value),
|
||||
unit = COALESCE(standard_fields.unit, EXCLUDED.unit),
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
enabled = standard_fields.enabled OR EXCLUDED.enabled,
|
||||
mapping_hints = CASE
|
||||
WHEN standard_fields.mapping_hints IS NULL
|
||||
OR standard_fields.mapping_hints = '[]'::jsonb
|
||||
THEN EXCLUDED.mapping_hints
|
||||
ELSE standard_fields.mapping_hints
|
||||
END,
|
||||
updated_at = now()`,
|
||||
companyID, f.Name, f.Key, f.Type, gid, f.Required, desc, defVal,
|
||||
f.Enabled, unit, f.SortOrder, string(hints))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package catalog
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSystemImmutable = errors.New("system records cannot be modified or deleted")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// clientError is a validation/business message safe to return to API clients.
|
||||
type clientError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.msg }
|
||||
|
||||
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
|
||||
func ClientMsg(msg string) error {
|
||||
return &clientError{msg: msg}
|
||||
}
|
||||
|
||||
// ClientError reports whether err is a known client-facing catalog error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.msg, true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return "not found", true
|
||||
case errors.Is(err, ErrSystemImmutable):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const maxUploadBytes = 5 << 20 // 5 MiB
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "upload.csv"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return "upload.csv"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) SaveUpload(ctx context.Context, companyID, userID uuid.UUID, uploadDir, originalName, contentType, kind string, r io.Reader) (map[string]any, error) {
|
||||
uploadDir = strings.TrimSpace(uploadDir)
|
||||
if uploadDir == "" {
|
||||
return nil, ClientMsg("upload directory not configured")
|
||||
}
|
||||
safe := sanitizeFileName(originalName)
|
||||
lower := strings.ToLower(safe)
|
||||
if !strings.HasSuffix(lower, ".csv") {
|
||||
return nil, ClientMsg("only .csv uploads are allowed")
|
||||
}
|
||||
if contentType != "" &&
|
||||
!strings.Contains(strings.ToLower(contentType), "csv") &&
|
||||
!strings.Contains(strings.ToLower(contentType), "text/plain") &&
|
||||
!strings.Contains(strings.ToLower(contentType), "octet-stream") {
|
||||
return nil, ClientMsg("invalid content type for CSV upload")
|
||||
}
|
||||
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if kind == "" {
|
||||
kind = "products"
|
||||
}
|
||||
metaBytes, _ := json.Marshal(map[string]any{"kind": kind})
|
||||
|
||||
fileID := uuid.New()
|
||||
dir := filepath.Join(uploadDir, companyID.String())
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(companyID.String(), fileID.String()+"-"+safe))
|
||||
abs := filepath.Join(uploadDir, filepath.FromSlash(rel))
|
||||
|
||||
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, io.LimitReader(r, maxUploadBytes+1))
|
||||
if err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, err
|
||||
}
|
||||
if n > maxUploadBytes {
|
||||
_ = os.Remove(abs)
|
||||
return nil, ClientMsg(fmt.Sprintf("file exceeds %d byte limit", maxUploadBytes))
|
||||
}
|
||||
|
||||
var uid any
|
||||
if userID != uuid.Nil {
|
||||
uid = userID
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO files (id, company_id, user_id, name, path, content_type, size_bytes, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8::jsonb)
|
||||
RETURNING id`, fileID, companyID, uid, safe, rel, contentType, n, string(metaBytes)).Scan(&id)
|
||||
if err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id.String(),
|
||||
"name": safe,
|
||||
"path": rel,
|
||||
"content_type": contentType,
|
||||
"size_bytes": n,
|
||||
"status": "uploaded",
|
||||
"kind": kind,
|
||||
"metadata": map[string]any{"kind": kind},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUploadPath(uploadDir string, companyID uuid.UUID, rel string) (string, error) {
|
||||
uploadDir = strings.TrimSpace(uploadDir)
|
||||
if uploadDir == "" {
|
||||
return "", ClientMsg("upload directory not configured")
|
||||
}
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", ClientMsg("invalid path")
|
||||
}
|
||||
prefix := companyID.String() + "/"
|
||||
if !strings.HasPrefix(rel, prefix) {
|
||||
return "", ClientMsg("forbidden")
|
||||
}
|
||||
base, err := filepath.Abs(uploadDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(filepath.Join(uploadDir, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sep := string(os.PathSeparator)
|
||||
if abs != base && !strings.HasPrefix(abs, base+sep) {
|
||||
return "", ClientMsg("forbidden")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func scanFileRow(rows pgx.Row) (map[string]any, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
companyID uuid.UUID
|
||||
userID *uuid.UUID
|
||||
name string
|
||||
path *string
|
||||
contentType *string
|
||||
sizeBytes int64
|
||||
status string
|
||||
metadata []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &companyID, &userID, &name, &path, &contentType, &sizeBytes, &status, &metadata, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta any = map[string]any{}
|
||||
if len(metadata) > 0 {
|
||||
_ = json.Unmarshal(metadata, &meta)
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": id.String(),
|
||||
"company_id": companyID.String(),
|
||||
"name": name,
|
||||
"size_bytes": sizeBytes,
|
||||
"status": status,
|
||||
"metadata": meta,
|
||||
"created_at": createdAt.UTC().Format(time.RFC3339),
|
||||
"updated_at": updatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if userID != nil {
|
||||
out["user_id"] = userID.String()
|
||||
}
|
||||
if path != nil {
|
||||
out["path"] = *path
|
||||
}
|
||||
if contentType != nil {
|
||||
out["content_type"] = *contentType
|
||||
}
|
||||
if m, ok := meta.(map[string]any); ok {
|
||||
if k, ok := m["kind"].(string); ok {
|
||||
out["kind"] = k
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListFiles(ctx context.Context, companyID uuid.UUID, f ListFilter) ([]map[string]any, int, error) {
|
||||
f = NormalizeListFilter(f)
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM files WHERE company_id = $1`, companyID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
|
||||
FROM files
|
||||
WHERE company_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, companyID, f.Limit, f.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanFileRow(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetFile(ctx context.Context, companyID, fileID uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
|
||||
FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
|
||||
item, err := scanFileRow(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFileStatus(ctx context.Context, companyID, fileID uuid.UUID, status string, metadata map[string]any) (map[string]any, error) {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
switch status {
|
||||
case "uploaded", "processing", "completed", "failed":
|
||||
default:
|
||||
return nil, ClientMsg("invalid file status")
|
||||
}
|
||||
metaBytes := []byte("{}")
|
||||
if metadata != nil {
|
||||
b, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metaBytes = b
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE files
|
||||
SET status = $3,
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = $2`, companyID, fileID, status, string(metaBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFile(ctx, companyID, fileID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFile(ctx context.Context, companyID, fileID uuid.UUID, uploadDir string) error {
|
||||
item, err := s.GetFile(ctx, companyID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
if pathStr, ok := item["path"].(string); ok && pathStr != "" {
|
||||
if abs, err := s.ResolveUploadPath(uploadDir, companyID, pathStr); err == nil {
|
||||
_ = os.Remove(abs)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestResolveUploadPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := t.TempDir()
|
||||
cid := uuid.New()
|
||||
rel := cid.String() + "/sample.csv"
|
||||
absWant := filepath.Join(base, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(absWant), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(absWant, []byte("a,b\n1,2\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &Service{}
|
||||
got, err := svc.ResolveUploadPath(base, cid, rel)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if filepath.Clean(got) != filepath.Clean(absWant) {
|
||||
t.Fatalf("got %q want %q", got, absWant)
|
||||
}
|
||||
|
||||
if _, err := svc.ResolveUploadPath("", cid, rel); err == nil {
|
||||
t.Fatal("expected empty upload dir reject")
|
||||
}
|
||||
if _, err := svc.ResolveUploadPath(base, cid, "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected traversal reject")
|
||||
}
|
||||
if _, err := svc.ResolveUploadPath(base, cid, cid.String()+"/../outside.csv"); err == nil {
|
||||
t.Fatal("expected nested traversal reject")
|
||||
}
|
||||
other := uuid.New()
|
||||
if _, err := svc.ResolveUploadPath(base, cid, other.String()+"/x.csv"); err == nil {
|
||||
t.Fatal("expected company mismatch reject")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeListFilterDefaults(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{})
|
||||
if f.Limit != 50 {
|
||||
t.Fatalf("default limit: got %d", f.Limit)
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("default offset: got %d", f.Offset)
|
||||
}
|
||||
if f.SortBy != "updatedAt" {
|
||||
t.Fatalf("default sortBy: got %q", f.SortBy)
|
||||
}
|
||||
if f.SortOrder != "desc" {
|
||||
t.Fatalf("default sortOrder: got %q", f.SortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeListFilterCaps(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{Limit: 9000, Offset: -3, Query: " abc ", SortBy: "bogus", SortOrder: "ASC"})
|
||||
if f.Limit != 2000 {
|
||||
t.Fatalf("cap limit: got %d", f.Limit)
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("offset floor: got %d", f.Offset)
|
||||
}
|
||||
if f.Query != "abc" {
|
||||
t.Fatalf("query trim: got %q", f.Query)
|
||||
}
|
||||
if f.SortBy != "updatedAt" {
|
||||
t.Fatalf("invalid sortBy fallback: got %q", f.SortBy)
|
||||
}
|
||||
if f.SortOrder != "asc" {
|
||||
t.Fatalf("sortOrder normalize: got %q", f.SortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProductListFilterKeysetEnforcement(t *testing.T) {
|
||||
ok, err := normalizeProductListFilter(ListFilter{Limit: 9000, Offset: MaxOffsetWithoutCursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok.Limit != MaxProductPageLimit {
|
||||
t.Fatalf("product limit cap: got %d", ok.Limit)
|
||||
}
|
||||
if ok.Offset != MaxOffsetWithoutCursor {
|
||||
t.Fatalf("offset at cap should pass: got %d", ok.Offset)
|
||||
}
|
||||
|
||||
_, err = normalizeProductListFilter(ListFilter{Offset: MaxOffsetWithoutCursor + 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected deep offset rejected")
|
||||
}
|
||||
if msg, ok := ClientError(err); !ok || !strings.Contains(msg, "cursor") {
|
||||
t.Fatalf("client msg: %v", err)
|
||||
}
|
||||
|
||||
cur, err := normalizeProductListFilter(ListFilter{
|
||||
Offset: MaxOffsetWithoutCursor + 50,
|
||||
AfterID: "00000000-0000-4000-8000-000000000001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur.Offset != 0 {
|
||||
t.Fatalf("cursor should clear offset: %d", cur.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRawProductFiltersSearchShape(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"rp.company_id = $1"}
|
||||
args, where = appendRawProductFilters(ListFilter{
|
||||
Query: " widget ",
|
||||
Status: "unprocessed",
|
||||
FeedID: "00000000-0000-4000-8000-000000000001",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("args len=%d want 4 (company, query, status, feed)", len(args))
|
||||
}
|
||||
if got, ok := args[1].(string); !ok || got != "% widget %" {
|
||||
// Query is not trimmed here — callers NormalizeListFilter first.
|
||||
t.Fatalf("query bind: %#v", args[1])
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
for _, need := range []string{
|
||||
"rp.gtin ILIKE",
|
||||
"mapped_data->>'name'",
|
||||
"mapped_data->>'title'",
|
||||
"f.name",
|
||||
"rp.processing_status = $3",
|
||||
"rp.feed_id = $4",
|
||||
} {
|
||||
if !strings.Contains(wSQL, need) {
|
||||
t.Fatalf("missing %q in %q", need, wSQL)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{
|
||||
"CAST(rp.mapped_data AS text)",
|
||||
"CAST(rp.raw_data AS text)",
|
||||
"attributes",
|
||||
"@>",
|
||||
} {
|
||||
if strings.Contains(wSQL, banned) {
|
||||
t.Fatalf("unexpected %q in %q", banned, wSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedProductFiltersSearchShape(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Query: "sku-1",
|
||||
Status: "published",
|
||||
Category: "cat-a",
|
||||
FeedID: "00000000-0000-4000-8000-000000000002",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 5 {
|
||||
t.Fatalf("args len=%d want 5", len(args))
|
||||
}
|
||||
if got, ok := args[1].(string); !ok || got != "%sku-1%" {
|
||||
t.Fatalf("query bind: %#v", args[1])
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
for _, need := range []string{
|
||||
"p.name ILIKE",
|
||||
"processed_name",
|
||||
"p.product_id ILIKE",
|
||||
"p.category ILIKE",
|
||||
"r.gtin",
|
||||
"p.status = $3",
|
||||
"p.category = $4",
|
||||
"EXISTS (",
|
||||
"p.feed_id = $5",
|
||||
} {
|
||||
if !strings.Contains(wSQL, need) {
|
||||
t.Fatalf("missing %q in %q", need, wSQL)
|
||||
}
|
||||
}
|
||||
// Product JSON attributes are returned by detailed list APIs, not filtered in SQL.
|
||||
for _, banned := range []string{
|
||||
"p.attributes",
|
||||
"processed_attributes",
|
||||
"CAST(",
|
||||
"@>",
|
||||
} {
|
||||
if strings.Contains(wSQL, banned) {
|
||||
t.Fatalf("unexpected %q in %q", banned, wSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCoverageFilter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"all": "",
|
||||
"Complete": "complete",
|
||||
"partial": "incomplete",
|
||||
"missing-attributes": "missing_attributes",
|
||||
"attrs": "missing_attributes",
|
||||
"name": "missing_name",
|
||||
"bogus": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeCoverageFilter(in); got != want {
|
||||
t.Fatalf("normalizeCoverageFilter(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEprelFilter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"all": "",
|
||||
"has_eprel": "has_eprel",
|
||||
"with-eprel": "has_eprel",
|
||||
"no_eprel": "no_eprel",
|
||||
"missing_eprel": "no_eprel",
|
||||
"bogus": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeEprelFilter(in); got != want {
|
||||
t.Fatalf("normalizeEprelFilter(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedEprelFilter(t *testing.T) {
|
||||
where := appendProcessedEprelFilter("has_eprel", []string{"p.company_id = $1"})
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "eprel_id") {
|
||||
t.Fatalf("expected eprel predicate in %q", wSQL)
|
||||
}
|
||||
if !processedListNeedsRawJoin(ProductFilter{Eprel: "has_eprel"}) {
|
||||
t.Fatal("eprel filter must force raw join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedCoverageFilter(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Coverage: "missing_attributes",
|
||||
}, args, where)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("coverage should not bind args; got %d", len(args))
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "NOT") || !strings.Contains(wSQL, "processed_attributes") {
|
||||
t.Fatalf("expected missing attributes predicate in %q", wSQL)
|
||||
}
|
||||
if !processedListNeedsRawJoin(ProductFilter{Coverage: "incomplete"}) {
|
||||
t.Fatal("coverage filter must force raw join for count")
|
||||
}
|
||||
if processedListNeedsRawJoin(ProductFilter{}) {
|
||||
t.Fatal("empty filter should not force raw join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedProductFiltersNeedsReviewAlias(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Status: "needs_review",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("needs_review should not bind status arg; args len=%d want 1", len(args))
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "p.status IN ('needs_review', 'processed')") {
|
||||
t.Fatalf("expected legacy processed alias in %q", wSQL)
|
||||
}
|
||||
|
||||
args2 := []any{"company"}
|
||||
where2 := []string{"p.company_id = $1"}
|
||||
args2, where2 = appendProcessedProductFilters(ProductFilter{
|
||||
Status: "completed",
|
||||
}, args2, where2)
|
||||
if len(args2) != 2 {
|
||||
t.Fatalf("completed should bind status; args len=%d want 2", len(args2))
|
||||
}
|
||||
w2 := strings.Join(where2, " AND ")
|
||||
if !strings.Contains(w2, "p.status = $2") {
|
||||
t.Fatalf("expected exact completed filter in %q", w2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawAndProcessedCountFromSQL(t *testing.T) {
|
||||
rawJoin := rawProductsCountFromSQL(true)
|
||||
rawPlain := rawProductsCountFromSQL(false)
|
||||
if !strings.Contains(rawJoin, "LEFT JOIN input_feeds") {
|
||||
t.Fatalf("raw search count needs feed join: %q", rawJoin)
|
||||
}
|
||||
if strings.Contains(rawPlain, "LEFT JOIN") {
|
||||
t.Fatalf("raw count without search should skip feed join: %q", rawPlain)
|
||||
}
|
||||
procJoin := processedProductsCountFromSQL(true)
|
||||
procPlain := processedProductsCountFromSQL(false)
|
||||
if !strings.Contains(procJoin, "LEFT JOIN raw_products") {
|
||||
t.Fatalf("processed search count needs raw join: %q", procJoin)
|
||||
}
|
||||
if strings.Contains(procPlain, "LEFT JOIN") {
|
||||
t.Fatalf("processed count without search should skip raw join: %q", procPlain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawProductsOrderBy(t *testing.T) {
|
||||
created := rawProductsOrderBy(ListFilter{SortBy: "createdAt", SortOrder: "desc"})
|
||||
if !strings.Contains(created, "rp.created_at DESC") {
|
||||
t.Fatalf("createdAt order: %q", created)
|
||||
}
|
||||
updated := rawProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "asc"})
|
||||
if !strings.Contains(updated, "rp.updated_at ASC") {
|
||||
t.Fatalf("updatedAt order: %q", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessedProductsOrderBy(t *testing.T) {
|
||||
ascName := processedProductsOrderBy(ListFilter{SortBy: "name", SortOrder: "asc"})
|
||||
if !strings.Contains(ascName, "processed_name") || !strings.Contains(ascName, "ASC") {
|
||||
t.Fatalf("name asc order: %q", ascName)
|
||||
}
|
||||
if !strings.Contains(ascName, "THEN 1 ELSE 0") {
|
||||
t.Fatalf("expected blank names last: %q", ascName)
|
||||
}
|
||||
descUpdated := processedProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "desc"})
|
||||
if !strings.Contains(descUpdated, "p.updated_at DESC") {
|
||||
t.Fatalf("updated desc order: %q", descUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactTotalFromPage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
offset, limit int
|
||||
pageLen int
|
||||
wantTotal int64
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "empty first page", offset: 0, limit: 50, pageLen: 0, wantTotal: 0, wantOK: true},
|
||||
{name: "short first page", offset: 0, limit: 50, pageLen: 12, wantTotal: 12, wantOK: true},
|
||||
{name: "full first page", offset: 0, limit: 50, pageLen: 50, wantOK: false},
|
||||
{name: "short later page", offset: 100, limit: 50, pageLen: 3, wantTotal: 103, wantOK: true},
|
||||
{name: "empty later page", offset: 100, limit: 50, pageLen: 0, wantOK: false},
|
||||
{name: "invalid limit", offset: 0, limit: 0, pageLen: 0, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := exactTotalFromPage(c.offset, c.limit, c.pageLen)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok=%v want %v", ok, c.wantOK)
|
||||
}
|
||||
if ok && got != c.wantTotal {
|
||||
t.Fatalf("total=%d want %d", got, c.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCountAndList(t *testing.T) {
|
||||
items, total, err := parallelCountAndList(context.Background(),
|
||||
1, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return 42, nil
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return []map[string]any{{"id": "a"}}, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 42 || len(items) != 1 {
|
||||
t.Fatalf("total=%d items=%d", total, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCountAndListSkipsCountOnShortPage(t *testing.T) {
|
||||
countCalls := 0
|
||||
items, total, err := parallelCountAndList(context.Background(),
|
||||
50, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
countCalls++
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return 999, nil
|
||||
}
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "a"}, {"id": "b"}}, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 2 {
|
||||
t.Fatalf("total=%d items=%d", total, len(items))
|
||||
}
|
||||
// Count may have started; short-page path must not wait on / require its success.
|
||||
_ = countCalls
|
||||
}
|
||||
|
||||
func TestParallelCountAndListPropagatesErrors(t *testing.T) {
|
||||
_, _, err := parallelCountAndList(context.Background(),
|
||||
1, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
return 0, errors.New("count failed")
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "a"}}, nil
|
||||
},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "count failed") {
|
||||
t.Fatalf("expected count error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderIndex(t *testing.T) {
|
||||
headers := []string{"Name", "unique_id", "GTIN"}
|
||||
if headerIndex(headers, "unique_id", "id") != 1 {
|
||||
t.Fatal("expected unique_id at 1")
|
||||
}
|
||||
if headerIndex(headers, "gtin", "ean") != 2 {
|
||||
t.Fatal("expected gtin at 2")
|
||||
}
|
||||
if headerIndex(headers, "missing") != -1 {
|
||||
t.Fatal("expected missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFileName(t *testing.T) {
|
||||
if sanitizeFileName("../evil.csv") != "evil.csv" {
|
||||
t.Fatalf("got %q", sanitizeFileName("../evil.csv"))
|
||||
}
|
||||
if sanitizeFileName("a b*.csv") != "a_b_.csv" {
|
||||
t.Fatalf("got %q", sanitizeFileName("a b*.csv"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
importCSVBatchSize = 500
|
||||
importCSVMaxErrors = 50
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ImportResult) addError(msg string) {
|
||||
if len(r.Errors) >= importCSVMaxErrors {
|
||||
return
|
||||
}
|
||||
r.Errors = append(r.Errors, msg)
|
||||
}
|
||||
|
||||
func headerIndex(headers []string, names ...string) int {
|
||||
want := map[string]struct{}{}
|
||||
for _, n := range names {
|
||||
want[strings.ToLower(strings.TrimSpace(n))] = struct{}{}
|
||||
}
|
||||
for i, h := range headers {
|
||||
if _, ok := want[strings.ToLower(strings.TrimSpace(h))]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func cell(row []string, idx int) string {
|
||||
if idx < 0 || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
type categoryCSVRow struct {
|
||||
name string
|
||||
uniqueID string
|
||||
parent *string
|
||||
desc *string
|
||||
}
|
||||
|
||||
type categoryPathInfo struct {
|
||||
id uuid.UUID
|
||||
path string
|
||||
level int
|
||||
}
|
||||
|
||||
func (s *Service) ImportCategoriesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iName := headerIndex(headers, "name")
|
||||
iUID := headerIndex(headers, "unique_id", "id", "category_id")
|
||||
iParent := headerIndex(headers, "parent_unique_id", "parent_id", "parent")
|
||||
iDesc := headerIndex(headers, "description")
|
||||
if iName < 0 || iUID < 0 {
|
||||
return res, ClientMsg("CSV must include name and unique_id columns")
|
||||
}
|
||||
|
||||
known := map[string]categoryPathInfo{}
|
||||
batch := make([]categoryCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
name := cell(row, iName)
|
||||
uid := cell(row, iUID)
|
||||
if name == "" || uid == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
var parent *string
|
||||
if p := cell(row, iParent); p != "" {
|
||||
parent = &p
|
||||
}
|
||||
var desc *string
|
||||
if d := cell(row, iDesc); d != "" {
|
||||
desc = &d
|
||||
}
|
||||
batch = append(batch, categoryCSVRow{name: name, uniqueID: uid, parent: parent, desc: desc})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushCategoryBatch(ctx context.Context, companyID uuid.UUID, batch []categoryCSVRow, known map[string]categoryPathInfo, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byUID := make(map[string]categoryCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byUID[row.uniqueID]; !ok {
|
||||
order = append(order, row.uniqueID)
|
||||
}
|
||||
byUID[row.uniqueID] = row
|
||||
}
|
||||
|
||||
lookup := make([]string, 0, len(byUID)*2)
|
||||
seenLookup := map[string]struct{}{}
|
||||
addLookup := func(uid string) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := known[uid]; ok {
|
||||
return
|
||||
}
|
||||
if _, ok := seenLookup[uid]; ok {
|
||||
return
|
||||
}
|
||||
seenLookup[uid] = struct{}{}
|
||||
lookup = append(lookup, uid)
|
||||
}
|
||||
for _, row := range byUID {
|
||||
addLookup(row.uniqueID)
|
||||
if row.parent != nil {
|
||||
addLookup(*row.parent)
|
||||
}
|
||||
}
|
||||
if err := s.loadCategoryPaths(ctx, companyID, lookup, known); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updIDs := make([]uuid.UUID, 0, len(order))
|
||||
updNames := make([]string, 0, len(order))
|
||||
updDescs := make([]string, 0, len(order))
|
||||
pendingInserts := make([]categoryCSVRow, 0, len(order))
|
||||
|
||||
for _, uid := range order {
|
||||
row := byUID[uid]
|
||||
if info, ok := known[uid]; ok {
|
||||
updIDs = append(updIDs, info.id)
|
||||
updNames = append(updNames, row.name)
|
||||
updDescs = append(updDescs, deref(row.desc))
|
||||
continue
|
||||
}
|
||||
pendingInserts = append(pendingInserts, row)
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if len(updIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE categories AS c SET
|
||||
name = v.name,
|
||||
description = CASE WHEN v.description <> '' THEN v.description ELSE c.description END,
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, description)
|
||||
WHERE c.id = v.id AND c.company_id = $1`,
|
||||
companyID, updIDs, updNames, updDescs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updIDs)
|
||||
}
|
||||
|
||||
for len(pendingInserts) > 0 {
|
||||
insNames := make([]string, 0, len(pendingInserts))
|
||||
insUIDs := make([]string, 0, len(pendingInserts))
|
||||
insParents := make([]string, 0, len(pendingInserts))
|
||||
insDescs := make([]string, 0, len(pendingInserts))
|
||||
insPaths := make([]string, 0, len(pendingInserts))
|
||||
insLevels := make([]int32, 0, len(pendingInserts))
|
||||
next := make([]categoryCSVRow, 0, len(pendingInserts))
|
||||
|
||||
for _, row := range pendingInserts {
|
||||
path, level, parentVal, err := resolveCategoryPath(row.uniqueID, row.parent, known)
|
||||
if err != nil {
|
||||
next = append(next, row)
|
||||
continue
|
||||
}
|
||||
insNames = append(insNames, row.name)
|
||||
insUIDs = append(insUIDs, row.uniqueID)
|
||||
insParents = append(insParents, parentVal)
|
||||
insDescs = append(insDescs, deref(row.desc))
|
||||
insPaths = append(insPaths, path)
|
||||
insLevels = append(insLevels, int32(level))
|
||||
}
|
||||
|
||||
if len(insUIDs) == 0 {
|
||||
for _, row := range next {
|
||||
res.Skipped++
|
||||
res.addError(fmt.Sprintf("%s: parent category not found", row.uniqueID))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
INSERT INTO categories (company_id, name, unique_id, parent_unique_id, description, path, level)
|
||||
SELECT $1, v.name, v.unique_id, NULLIF(v.parent_unique_id, ''), NULLIF(v.description, ''), v.path, v.level
|
||||
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::int[])
|
||||
AS v(name, unique_id, parent_unique_id, description, path, level)
|
||||
RETURNING id, unique_id, COALESCE(path, ''), level`,
|
||||
companyID, insNames, insUIDs, insParents, insDescs, insPaths, insLevels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var uid, path string
|
||||
var level int
|
||||
if err := rows.Scan(&id, &uid, &path, &level); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
known[uid] = categoryPathInfo{id: id, path: path, level: level}
|
||||
res.Created++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingInserts = next
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) loadCategoryPaths(ctx context.Context, companyID uuid.UUID, uids []string, known map[string]categoryPathInfo) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, unique_id, COALESCE(path, ''), level
|
||||
FROM categories
|
||||
WHERE company_id = $1 AND unique_id = ANY($2)`, companyID, uids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var info categoryPathInfo
|
||||
var uid string
|
||||
if err := rows.Scan(&info.id, &uid, &info.path, &info.level); err != nil {
|
||||
return err
|
||||
}
|
||||
known[uid] = info
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func resolveCategoryPath(uniqueID string, parent *string, known map[string]categoryPathInfo) (path string, level int, parentVal string, err error) {
|
||||
path = uniqueID
|
||||
level = 0
|
||||
if parent == nil || strings.TrimSpace(*parent) == "" {
|
||||
return path, level, "", nil
|
||||
}
|
||||
p := strings.TrimSpace(*parent)
|
||||
pinfo, ok := known[p]
|
||||
if !ok {
|
||||
return "", 0, "", errors.New("parent category not found")
|
||||
}
|
||||
if pinfo.path != "" {
|
||||
path = pinfo.path + "/" + uniqueID
|
||||
} else {
|
||||
path = p + "/" + uniqueID
|
||||
}
|
||||
return path, pinfo.level + 1, p, nil
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
type attributeCSVRow struct {
|
||||
key, name, valueType string
|
||||
unit, example, parent *string
|
||||
}
|
||||
|
||||
func (s *Service) ImportAttributesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iKey := headerIndex(headers, "attribute_key", "key")
|
||||
iName := headerIndex(headers, "name")
|
||||
iType := headerIndex(headers, "value_type", "type")
|
||||
iUnit := headerIndex(headers, "unit")
|
||||
iExample := headerIndex(headers, "example")
|
||||
iParent := headerIndex(headers, "parent_key", "parent")
|
||||
if iKey < 0 || iName < 0 {
|
||||
return res, ClientMsg("CSV must include attribute_key and name columns")
|
||||
}
|
||||
|
||||
batch := make([]attributeCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
key := cell(row, iKey)
|
||||
name := cell(row, iName)
|
||||
if key == "" || name == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
valueType := cell(row, iType)
|
||||
if valueType == "" {
|
||||
valueType = "string"
|
||||
}
|
||||
var unit, example, parent *string
|
||||
if u := cell(row, iUnit); u != "" {
|
||||
unit = &u
|
||||
}
|
||||
if e := cell(row, iExample); e != "" {
|
||||
example = &e
|
||||
}
|
||||
if p := cell(row, iParent); p != "" {
|
||||
parent = &p
|
||||
}
|
||||
batch = append(batch, attributeCSVRow{
|
||||
key: key, name: name, valueType: valueType,
|
||||
unit: unit, example: example, parent: parent,
|
||||
})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushAttributeBatch(ctx context.Context, companyID uuid.UUID, batch []attributeCSVRow, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byKey := make(map[string]attributeCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byKey[row.key]; !ok {
|
||||
order = append(order, row.key)
|
||||
}
|
||||
byKey[row.key] = row
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(byKey))
|
||||
keys = append(keys, order...)
|
||||
existing := map[string]uuid.UUID{}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, attribute_key FROM attributes
|
||||
WHERE company_id = $1 AND attribute_key = ANY($2)`, companyID, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var key string
|
||||
if err := rows.Scan(&id, &key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing[key] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updIDs := make([]uuid.UUID, 0, len(order))
|
||||
updNames := make([]string, 0, len(order))
|
||||
updTypes := make([]string, 0, len(order))
|
||||
insKeys := make([]string, 0, len(order))
|
||||
insNames := make([]string, 0, len(order))
|
||||
insTypes := make([]string, 0, len(order))
|
||||
insUnits := make([]string, 0, len(order))
|
||||
insExamples := make([]string, 0, len(order))
|
||||
insParents := make([]string, 0, len(order))
|
||||
|
||||
for _, key := range order {
|
||||
row := byKey[key]
|
||||
if id, ok := existing[key]; ok {
|
||||
updIDs = append(updIDs, id)
|
||||
updNames = append(updNames, row.name)
|
||||
updTypes = append(updTypes, row.valueType)
|
||||
continue
|
||||
}
|
||||
insKeys = append(insKeys, key)
|
||||
insNames = append(insNames, row.name)
|
||||
insTypes = append(insTypes, row.valueType)
|
||||
insUnits = append(insUnits, deref(row.unit))
|
||||
insExamples = append(insExamples, deref(row.example))
|
||||
insParents = append(insParents, deref(row.parent))
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if len(updIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE attributes AS a SET
|
||||
name = CASE WHEN v.name <> '' THEN v.name ELSE a.name END,
|
||||
value_type = CASE WHEN v.value_type <> '' THEN v.value_type ELSE a.value_type END,
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, value_type)
|
||||
WHERE a.id = v.id AND a.company_id = $1`,
|
||||
companyID, updIDs, updNames, updTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updIDs)
|
||||
}
|
||||
|
||||
if len(insKeys) > 0 {
|
||||
ct, err := tx.Exec(ctx, `
|
||||
INSERT INTO attributes (company_id, attribute_key, name, value_type, unit, example, parent_key)
|
||||
SELECT $1, v.attribute_key, v.name, v.value_type,
|
||||
NULLIF(v.unit, ''), NULLIF(v.example, ''), NULLIF(v.parent_key, '')
|
||||
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(attribute_key, name, value_type, unit, example, parent_key)`,
|
||||
companyID, insKeys, insNames, insTypes, insUnits, insExamples, insParents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Created += int(ct.RowsAffected())
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProductsByGTIN(ctx context.Context, companyID uuid.UUID) (bool, error) {
|
||||
var merge bool
|
||||
err := s.Pool.QueryRow(ctx, `SELECT merge_products_by_gtin FROM companies WHERE id = $1`, companyID).Scan(&merge)
|
||||
return merge, err
|
||||
}
|
||||
|
||||
type productCSVRow struct {
|
||||
gtin, productID, name, category, desc, status string
|
||||
rawJSON string
|
||||
mergeable bool
|
||||
}
|
||||
|
||||
func (s *Service) ImportProductsCSV(ctx context.Context, companyID uuid.UUID, r io.Reader, fileID *uuid.UUID) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
merge, err := s.MergeProductsByGTIN(ctx, companyID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iGTIN := headerIndex(headers, "gtin", "ean", "barcode")
|
||||
iPID := headerIndex(headers, "product_id", "sku", "id")
|
||||
iName := headerIndex(headers, "name", "title")
|
||||
iCat := headerIndex(headers, "category")
|
||||
iDesc := headerIndex(headers, "description")
|
||||
iStatus := headerIndex(headers, "status")
|
||||
if iName < 0 && iGTIN < 0 && iPID < 0 {
|
||||
return res, ClientMsg("CSV must include name, gtin, or product_id")
|
||||
}
|
||||
|
||||
batch := make([]productCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
gtin := cell(row, iGTIN)
|
||||
productID := cell(row, iPID)
|
||||
name := cell(row, iName)
|
||||
category := cell(row, iCat)
|
||||
desc := cell(row, iDesc)
|
||||
status := cell(row, iStatus)
|
||||
if status == "" {
|
||||
status = "draft"
|
||||
}
|
||||
if gtin == "" && productID == "" && name == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if gtin == "" {
|
||||
gtin = "nogtin-" + uuid.NewString()
|
||||
}
|
||||
rawData, _ := json.Marshal(map[string]any{
|
||||
"product_id": productID,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"description": desc,
|
||||
"status": status,
|
||||
"gtin": gtin,
|
||||
})
|
||||
batch = append(batch, productCSVRow{
|
||||
gtin: gtin, productID: productID, name: name, category: category,
|
||||
desc: desc, status: status, rawJSON: string(rawData),
|
||||
mergeable: merge && !strings.HasPrefix(gtin, "nogtin-"),
|
||||
})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushProductBatch(ctx context.Context, companyID uuid.UUID, fileID *uuid.UUID, batch []productCSVRow, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Last row wins per GTIN so a single INSERT cannot hit the same unique key twice.
|
||||
byGTIN := make(map[string]productCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byGTIN[row.gtin]; !ok {
|
||||
order = append(order, row.gtin)
|
||||
}
|
||||
byGTIN[row.gtin] = row
|
||||
}
|
||||
deduped := make([]productCSVRow, 0, len(order))
|
||||
for _, gtin := range order {
|
||||
deduped = append(deduped, byGTIN[gtin])
|
||||
}
|
||||
batch = deduped
|
||||
|
||||
mergeGTINs := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if row.mergeable {
|
||||
mergeGTINs = append(mergeGTINs, row.gtin)
|
||||
}
|
||||
}
|
||||
|
||||
existingRaw := map[string]uuid.UUID{}
|
||||
if len(mergeGTINs) > 0 {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (gtin) id, gtin
|
||||
FROM raw_products
|
||||
WHERE company_id = $1 AND gtin = ANY($2)
|
||||
ORDER BY gtin, updated_at DESC`, companyID, mergeGTINs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var gtin string
|
||||
if err := rows.Scan(&id, >in); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existingRaw[gtin] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type pendingProcessed struct {
|
||||
rawID uuid.UUID
|
||||
productID, name, category, desc, status string
|
||||
rawWasUpdate bool
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
updRawIDs := make([]uuid.UUID, 0, len(batch))
|
||||
updRawJSON := make([]string, 0, len(batch))
|
||||
updRawMeta := make([]pendingProcessed, 0, len(batch))
|
||||
|
||||
insGTINs := make([]string, 0, len(batch))
|
||||
insJSON := make([]string, 0, len(batch))
|
||||
insMeta := make([]pendingProcessed, 0, len(batch))
|
||||
|
||||
for _, row := range batch {
|
||||
meta := pendingProcessed{
|
||||
productID: row.productID, name: row.name, category: row.category,
|
||||
desc: row.desc, status: row.status,
|
||||
}
|
||||
if row.mergeable {
|
||||
if id, ok := existingRaw[row.gtin]; ok {
|
||||
updRawIDs = append(updRawIDs, id)
|
||||
updRawJSON = append(updRawJSON, row.rawJSON)
|
||||
meta.rawID = id
|
||||
meta.rawWasUpdate = true
|
||||
updRawMeta = append(updRawMeta, meta)
|
||||
continue
|
||||
}
|
||||
}
|
||||
insGTINs = append(insGTINs, row.gtin)
|
||||
insJSON = append(insJSON, row.rawJSON)
|
||||
insMeta = append(insMeta, meta)
|
||||
}
|
||||
|
||||
if len(updRawIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE raw_products AS r SET
|
||||
raw_data = v.raw_data::jsonb,
|
||||
mapped_data = v.raw_data::jsonb,
|
||||
file_id = COALESCE($3, r.file_id),
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $4::text[]) AS v(id, raw_data)
|
||||
WHERE r.id = v.id AND r.company_id = $1`,
|
||||
companyID, updRawIDs, fileID, updRawJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pending := make([]pendingProcessed, 0, len(batch))
|
||||
pending = append(pending, updRawMeta...)
|
||||
|
||||
if len(insGTINs) > 0 {
|
||||
rows, err := tx.Query(ctx, `
|
||||
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, file_id)
|
||||
SELECT $1, v.gtin, v.raw_data::jsonb, v.raw_data::jsonb, 'unprocessed', $2
|
||||
FROM unnest($3::text[], $4::text[]) AS v(gtin, raw_data)
|
||||
ON CONFLICT (company_id, gtin) DO NOTHING
|
||||
RETURNING id, gtin`,
|
||||
companyID, fileID, insGTINs, insJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
insertedByGTIN := map[string]uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var gtin string
|
||||
if err := rows.Scan(&id, >in); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
insertedByGTIN[gtin] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Match inserts back to input order; conflicts (DO NOTHING) are skipped.
|
||||
for i, gtin := range insGTINs {
|
||||
id, ok := insertedByGTIN[gtin]
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
res.addError(fmt.Sprintf("%s: duplicate gtin", gtin))
|
||||
continue
|
||||
}
|
||||
meta := insMeta[i]
|
||||
meta.rawID = id
|
||||
pending = append(pending, meta)
|
||||
// Same gtin inserted twice in one batch: second RETURNING miss.
|
||||
delete(insertedByGTIN, gtin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
rawIDs := make([]uuid.UUID, len(pending))
|
||||
for i, p := range pending {
|
||||
rawIDs[i] = p.rawID
|
||||
}
|
||||
existingProcessed := map[uuid.UUID]uuid.UUID{}
|
||||
prows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT ON (raw_product_id) id, raw_product_id
|
||||
FROM processed_products
|
||||
WHERE company_id = $1 AND raw_product_id = ANY($2)
|
||||
ORDER BY raw_product_id, updated_at DESC`, companyID, rawIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for prows.Next() {
|
||||
var id, rawID uuid.UUID
|
||||
if err := prows.Scan(&id, &rawID); err != nil {
|
||||
prows.Close()
|
||||
return err
|
||||
}
|
||||
existingProcessed[rawID] = id
|
||||
}
|
||||
err = prows.Err()
|
||||
prows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updProcIDs := make([]uuid.UUID, 0, len(pending))
|
||||
updPIDs := make([]string, 0, len(pending))
|
||||
updNames := make([]string, 0, len(pending))
|
||||
updCats := make([]string, 0, len(pending))
|
||||
updDescs := make([]string, 0, len(pending))
|
||||
updStatuses := make([]string, 0, len(pending))
|
||||
|
||||
insRawIDs := make([]uuid.UUID, 0, len(pending))
|
||||
insPIDs := make([]string, 0, len(pending))
|
||||
insNames := make([]string, 0, len(pending))
|
||||
insCats := make([]string, 0, len(pending))
|
||||
insDescs := make([]string, 0, len(pending))
|
||||
insStatuses := make([]string, 0, len(pending))
|
||||
insWasUpdate := make([]bool, 0, len(pending))
|
||||
|
||||
for _, p := range pending {
|
||||
if pid, ok := existingProcessed[p.rawID]; ok {
|
||||
updProcIDs = append(updProcIDs, pid)
|
||||
updPIDs = append(updPIDs, p.productID)
|
||||
updNames = append(updNames, p.name)
|
||||
updCats = append(updCats, p.category)
|
||||
updDescs = append(updDescs, p.desc)
|
||||
updStatuses = append(updStatuses, p.status)
|
||||
continue
|
||||
}
|
||||
insRawIDs = append(insRawIDs, p.rawID)
|
||||
insPIDs = append(insPIDs, p.productID)
|
||||
insNames = append(insNames, p.name)
|
||||
insCats = append(insCats, p.category)
|
||||
insDescs = append(insDescs, p.desc)
|
||||
insStatuses = append(insStatuses, p.status)
|
||||
insWasUpdate = append(insWasUpdate, p.rawWasUpdate)
|
||||
}
|
||||
|
||||
if len(updProcIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE processed_products AS p SET
|
||||
product_id = COALESCE(NULLIF(v.product_id, ''), p.product_id),
|
||||
name = COALESCE(NULLIF(v.name, ''), p.name),
|
||||
category = COALESCE(NULLIF(v.category, ''), p.category),
|
||||
description = COALESCE(NULLIF(v.description, ''), p.description),
|
||||
status = COALESCE(NULLIF(v.status, ''), p.status),
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(id, product_id, name, category, description, status)
|
||||
WHERE p.id = v.id AND p.company_id = $1`,
|
||||
companyID, updProcIDs, updPIDs, updNames, updCats, updDescs, updStatuses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updProcIDs)
|
||||
}
|
||||
|
||||
if len(insRawIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO processed_products (company_id, product_id, name, category, description, status, raw_product_id)
|
||||
SELECT $1,
|
||||
NULLIF(v.product_id, ''), NULLIF(v.name, ''), NULLIF(v.category, ''),
|
||||
NULLIF(v.description, ''), v.status, v.raw_product_id
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(raw_product_id, product_id, name, category, description, status)`,
|
||||
companyID, insRawIDs, insPIDs, insNames, insCats, insDescs, insStatuses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, wasUpdate := range insWasUpdate {
|
||||
if wasUpdate {
|
||||
res.Updated++
|
||||
} else {
|
||||
res.Created++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestHeaderIndexAndCell(t *testing.T) {
|
||||
headers := []string{" Name ", "GTIN", "sku"}
|
||||
if got := headerIndex(headers, "name"); got != 0 {
|
||||
t.Fatalf("headerIndex name: got %d want 0", got)
|
||||
}
|
||||
if got := headerIndex(headers, "ean", "gtin"); got != 1 {
|
||||
t.Fatalf("headerIndex gtin: got %d want 1", got)
|
||||
}
|
||||
if got := headerIndex(headers, "missing"); got != -1 {
|
||||
t.Fatalf("headerIndex missing: got %d want -1", got)
|
||||
}
|
||||
row := []string{" a ", "b"}
|
||||
if got := cell(row, 0); got != "a" {
|
||||
t.Fatalf("cell: got %q want a", got)
|
||||
}
|
||||
if got := cell(row, 5); got != "" {
|
||||
t.Fatalf("cell OOB: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportResultAddErrorCaps(t *testing.T) {
|
||||
res := ImportResult{}
|
||||
for i := 0; i < importCSVMaxErrors+20; i++ {
|
||||
res.addError("err")
|
||||
}
|
||||
if len(res.Errors) != importCSVMaxErrors {
|
||||
t.Fatalf("errors capped: got %d want %d", len(res.Errors), importCSVMaxErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCategoryPath(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
known := map[string]categoryPathInfo{
|
||||
"parent": {id: parentID, path: "root/parent", level: 1},
|
||||
}
|
||||
path, level, parentVal, err := resolveCategoryPath("child", strPtr("parent"), known)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if path != "root/parent/child" || level != 2 || parentVal != "parent" {
|
||||
t.Fatalf("got path=%q level=%d parent=%q", path, level, parentVal)
|
||||
}
|
||||
_, _, _, err = resolveCategoryPath("child", strPtr("missing"), known)
|
||||
if err == nil || err.Error() != "parent category not found" {
|
||||
t.Fatalf("got %v, want parent category not found", err)
|
||||
}
|
||||
path, level, parentVal, err = resolveCategoryPath("root", nil, known)
|
||||
if err != nil || path != "root" || level != 0 || parentVal != "" {
|
||||
t.Fatalf("root: path=%q level=%d parent=%q err=%v", path, level, parentVal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCategoriesAndAttributesCSVBatch(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "csv-import-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
prefix := companyID.String()[:8]
|
||||
|
||||
catCSV := strings.NewReader("name,unique_id,parent_unique_id,description\n" +
|
||||
"Root,root-" + prefix + ",,root desc\n" +
|
||||
"Child,child-" + prefix + ",root-" + prefix + ",child desc\n")
|
||||
catRes, err := svc.ImportCategoriesCSV(ctx, companyID, catCSV)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportCategoriesCSV: %v", err)
|
||||
}
|
||||
if catRes.Created != 2 || catRes.Updated != 0 {
|
||||
t.Fatalf("categories create: %+v want created=2 updated=0", catRes)
|
||||
}
|
||||
|
||||
catUpdate := strings.NewReader("name,unique_id,description\n" +
|
||||
"Root,root-" + prefix + ",root updated\n")
|
||||
catRes2, err := svc.ImportCategoriesCSV(ctx, companyID, catUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportCategoriesCSV update: %v", err)
|
||||
}
|
||||
if catRes2.Created != 0 || catRes2.Updated != 1 {
|
||||
t.Fatalf("categories update: %+v want created=0 updated=1", catRes2)
|
||||
}
|
||||
|
||||
var rootName, rootDesc, childPath string
|
||||
var childLevel int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT name, COALESCE(description, '') FROM categories
|
||||
WHERE company_id = $1 AND unique_id = $2`, companyID, "root-"+prefix).
|
||||
Scan(&rootName, &rootDesc)
|
||||
if err != nil {
|
||||
t.Fatalf("select root: %v", err)
|
||||
}
|
||||
if rootName != "Root" || rootDesc != "root updated" {
|
||||
t.Fatalf("root fields: name=%q desc=%q", rootName, rootDesc)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT COALESCE(path, ''), level FROM categories
|
||||
WHERE company_id = $1 AND unique_id = $2`, companyID, "child-"+prefix).
|
||||
Scan(&childPath, &childLevel)
|
||||
if err != nil {
|
||||
t.Fatalf("select child: %v", err)
|
||||
}
|
||||
wantPath := "root-" + prefix + "/child-" + prefix
|
||||
if childPath != wantPath || childLevel != 1 {
|
||||
t.Fatalf("child path=%q level=%d want %q / 1", childPath, childLevel, wantPath)
|
||||
}
|
||||
|
||||
attrCSV := strings.NewReader("attribute_key,name,value_type,unit\n" +
|
||||
"color,Color,string,\n" +
|
||||
"size,Size,string,cm\n")
|
||||
attrRes, err := svc.ImportAttributesCSV(ctx, companyID, attrCSV)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAttributesCSV: %v", err)
|
||||
}
|
||||
if attrRes.Created != 2 || attrRes.Updated != 0 {
|
||||
t.Fatalf("attributes create: %+v want created=2 updated=0", attrRes)
|
||||
}
|
||||
attrUpdate := strings.NewReader("attribute_key,name,value_type\n" +
|
||||
"color,Colour,string\n")
|
||||
attrRes2, err := svc.ImportAttributesCSV(ctx, companyID, attrUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAttributesCSV update: %v", err)
|
||||
}
|
||||
if attrRes2.Created != 0 || attrRes2.Updated != 1 {
|
||||
t.Fatalf("attributes update: %+v want created=0 updated=1", attrRes2)
|
||||
}
|
||||
var colorName string
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT name FROM attributes WHERE company_id = $1 AND attribute_key = 'color'`, companyID).
|
||||
Scan(&colorName)
|
||||
if err != nil {
|
||||
t.Fatalf("select color: %v", err)
|
||||
}
|
||||
if colorName != "Colour" {
|
||||
t.Fatalf("color name=%q want Colour", colorName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProductsCSVBatchMerge(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO companies (id, name, merge_products_by_gtin) VALUES ($1, $2, true)`,
|
||||
companyID, "csv-products-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
gtin := "590123412345" + companyID.String()[:3]
|
||||
csv1 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",First,SKU-1,draft\n")
|
||||
res1, err := svc.ImportProductsCSV(ctx, companyID, csv1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProductsCSV create: %v", err)
|
||||
}
|
||||
if res1.Created != 1 || res1.Updated != 0 {
|
||||
t.Fatalf("create result: %+v", res1)
|
||||
}
|
||||
|
||||
csv2 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",Second,SKU-2,published\n")
|
||||
res2, err := svc.ImportProductsCSV(ctx, companyID, csv2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProductsCSV update: %v", err)
|
||||
}
|
||||
if res2.Updated != 1 {
|
||||
t.Fatalf("update result: %+v want updated=1", res2)
|
||||
}
|
||||
|
||||
var rawCount int
|
||||
var name string
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT count(*) FROM raw_products WHERE company_id = $1 AND gtin = $2`, companyID, gtin).
|
||||
Scan(&rawCount)
|
||||
if err != nil {
|
||||
t.Fatalf("count raw: %v", err)
|
||||
}
|
||||
if rawCount != 1 {
|
||||
t.Fatalf("raw_products count=%d want 1", rawCount)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT COALESCE(p.name, '') FROM processed_products p
|
||||
JOIN raw_products r ON r.id = p.raw_product_id
|
||||
WHERE p.company_id = $1 AND r.gtin = $2`, companyID, gtin).Scan(&name)
|
||||
if err != nil {
|
||||
t.Fatalf("select processed: %v", err)
|
||||
}
|
||||
if name != "Second" {
|
||||
t.Fatalf("processed name=%q want Second", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCategoriesCSVRejectsMissingColumns(t *testing.T) {
|
||||
svc := &Service{}
|
||||
_, err := svc.ImportCategoriesCSV(context.Background(), uuid.New(), strings.NewReader("foo,bar\n1,2\n"))
|
||||
if err == nil || err.Error() != "CSV must include name and unique_id columns" {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,159 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
)
|
||||
|
||||
// linkFeedSpecificationsIntoProduct expands mapped_data.specifications HTML/flat
|
||||
// blobs into attribute_key→value maps and merges them into attributes when empty.
|
||||
// Mutates item in place for GET product responses (does not persist).
|
||||
func linkFeedSpecificationsIntoProduct(item map[string]any) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
mapped, _ := item["mapped_data"].(map[string]any)
|
||||
if mapped == nil {
|
||||
return
|
||||
}
|
||||
linked := extractLinkedSpecs(mapped)
|
||||
if len(linked) == 0 {
|
||||
return
|
||||
}
|
||||
// Prefer structured object in response mapped_data for the Attributes/Feed UI.
|
||||
if specs := mapped["specifications"]; isLooseSpecBlob(specs) {
|
||||
mapped["specifications"] = linked
|
||||
item["mapped_data"] = mapped
|
||||
} else if specs := mapped["specs"]; isLooseSpecBlob(specs) {
|
||||
mapped["specs"] = linked
|
||||
item["mapped_data"] = mapped
|
||||
}
|
||||
attrs := asStringAnyMap(item["attributes"])
|
||||
if len(attrs) == 0 {
|
||||
item["attributes"] = linked
|
||||
item["has_attributes"] = true
|
||||
return
|
||||
}
|
||||
merged := make(map[string]any, len(attrs)+len(linked))
|
||||
for k, v := range attrs {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range linked {
|
||||
if existing, ok := merged[k]; ok && strings.TrimSpace(stringifyAny(existing)) != "" {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
item["attributes"] = merged
|
||||
item["has_attributes"] = true
|
||||
}
|
||||
|
||||
func extractLinkedSpecs(mapped map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
put := func(label, value string) {
|
||||
k := feeds.CanonicalAttributeKey(label)
|
||||
if k == "" || strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := out[k]; exists {
|
||||
return
|
||||
}
|
||||
out[k] = value
|
||||
}
|
||||
for _, key := range []string{"specifications", "specs"} {
|
||||
v, ok := mapped[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
for _, p := range feeds.ParseSpecifications(t) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, val := range t {
|
||||
if strings.EqualFold(k, "_raw") {
|
||||
if s, ok := val.(string); ok {
|
||||
for _, p := range feeds.ParseSpecifications(s) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
put(k, stringifyAny(val))
|
||||
}
|
||||
case map[string]string:
|
||||
for k, val := range t {
|
||||
if strings.EqualFold(k, "_raw") {
|
||||
for _, p := range feeds.ParseSpecifications(val) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
put(k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scalar mapped fields → standard attribute keys (net_height, eprel_id, …).
|
||||
for _, src := range []string{
|
||||
"warranty", "eprel_id", "eprel",
|
||||
"netwidth", "net_width", "netheight", "net_height",
|
||||
"netdepth", "net_depth", "netmass", "net_mass",
|
||||
"productmodel", "product_model",
|
||||
"visina", "sirina", "globina", "teza",
|
||||
} {
|
||||
if s := stringifyAny(mapped[src]); s != "" {
|
||||
put(src, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isLooseSpecBlob(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t) != ""
|
||||
case map[string]any:
|
||||
_, hasRaw := t["_raw"]
|
||||
return hasRaw
|
||||
case map[string]string:
|
||||
_, hasRaw := t["_raw"]
|
||||
return hasRaw
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func asStringAnyMap(v any) map[string]any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
return t
|
||||
case map[string]string:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = val
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringifyAny(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64, float32, int, int64, bool:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLinkFeedSpecificationsIntoProductFillsAttributes(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"mapped_data": map[string]any{
|
||||
"description": "Feed original description",
|
||||
"category": "46",
|
||||
"specifications": "Barva: črna; Garancija: 24",
|
||||
"warranty": "24",
|
||||
"net_width": "10",
|
||||
},
|
||||
"attributes": map[string]any{},
|
||||
}
|
||||
linkFeedSpecificationsIntoProduct(item)
|
||||
attrs, _ := item["attributes"].(map[string]any)
|
||||
if len(attrs) == 0 {
|
||||
t.Fatal("expected attributes filled from mapped feed specs")
|
||||
}
|
||||
if item["has_attributes"] != true {
|
||||
t.Fatalf("has_attributes=%v want true", item["has_attributes"])
|
||||
}
|
||||
mapped, _ := item["mapped_data"].(map[string]any)
|
||||
if mapped["description"] != "Feed original description" {
|
||||
t.Fatalf("description stripped from mapped_data")
|
||||
}
|
||||
if mapped["category"] != "46" {
|
||||
t.Fatalf("category stripped from mapped_data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Service) ListCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string) ([]map[string]any, error) {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return nil, ClientMsg("category_unique_id required")
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
|
||||
a.attribute_key, a.name, a.value_type
|
||||
FROM category_attributes ca
|
||||
INNER JOIN attributes a ON a.id = ca.attribute_id AND a.company_id = ca.company_id
|
||||
WHERE ca.company_id = $1 AND ca.category_unique_id = $2
|
||||
ORDER BY a.name`, companyID, categoryUniqueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
|
||||
}
|
||||
|
||||
func (s *Service) LinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID, required bool) (map[string]any, error) {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return nil, ClientMsg("category_unique_id required")
|
||||
}
|
||||
var catExists bool
|
||||
if err := s.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
|
||||
companyID, categoryUniqueID).Scan(&catExists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !catExists {
|
||||
return nil, ClientMsg("category not found")
|
||||
}
|
||||
var attrCompany uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `SELECT company_id FROM attributes WHERE id = $1`, attributeID).Scan(&attrCompany)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ClientMsg("attribute not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if attrCompany != companyID {
|
||||
return nil, ClientMsg("attribute not found")
|
||||
}
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (company_id, category_unique_id, attribute_id)
|
||||
DO UPDATE SET required = EXCLUDED.required, updated_at = now()
|
||||
RETURNING id`, companyID, categoryUniqueID, attributeID, required).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
|
||||
a.attribute_key, a.name, a.value_type
|
||||
FROM category_attributes ca
|
||||
INNER JOIN attributes a ON a.id = ca.attribute_id
|
||||
WHERE ca.id = $1 AND ca.company_id = $2`, id, companyID)
|
||||
return scanMap(row, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
|
||||
}
|
||||
|
||||
func (s *Service) UnlinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID) error {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM category_attributes
|
||||
WHERE company_id = $1 AND category_unique_id = $2 AND attribute_id = $3`,
|
||||
companyID, categoryUniqueID, attributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ReplaceCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeIDs []uuid.UUID, required map[string]bool) error {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return ClientMsg("category_unique_id required")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var catExists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
|
||||
companyID, categoryUniqueID).Scan(&catExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !catExists {
|
||||
return ClientMsg("category not found")
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM category_attributes WHERE company_id = $1 AND category_unique_id = $2`,
|
||||
companyID, categoryUniqueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(attributeIDs) == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if required == nil {
|
||||
required = map[string]bool{}
|
||||
}
|
||||
|
||||
ownedRows, err := tx.Query(ctx, `
|
||||
SELECT id FROM attributes WHERE company_id = $1 AND id = ANY($2::uuid[])`,
|
||||
companyID, attributeIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned := make([]uuid.UUID, 0, len(attributeIDs))
|
||||
for ownedRows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := ownedRows.Scan(&id); err != nil {
|
||||
ownedRows.Close()
|
||||
return err
|
||||
}
|
||||
owned = append(owned, id)
|
||||
}
|
||||
err = ownedRows.Err()
|
||||
ownedRows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAttributeIDsOwned(attributeIDs, owned); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqs := make([]bool, len(attributeIDs))
|
||||
for i, aid := range attributeIDs {
|
||||
reqs[i] = required[aid.String()]
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
|
||||
SELECT $1, $2, u.attribute_id, u.required
|
||||
FROM unnest($3::uuid[], $4::boolean[]) AS u(attribute_id, required)`,
|
||||
companyID, categoryUniqueID, attributeIDs, reqs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// validateAttributeIDsOwned ensures every requested attribute ID is present in the
|
||||
// company-scoped ownership query result. Missing or cross-tenant IDs surface as
|
||||
// "attribute not found" (same message as the former per-row SELECT path).
|
||||
func validateAttributeIDsOwned(attributeIDs, owned []uuid.UUID) error {
|
||||
if len(attributeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[uuid.UUID]struct{}, len(owned))
|
||||
for _, id := range owned {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, aid := range attributeIDs {
|
||||
if _, ok := set[aid]; !ok {
|
||||
return ClientMsg("attribute not found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestValidateAttributeIDsOwned(t *testing.T) {
|
||||
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
c := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
|
||||
t.Run("empty request", func(t *testing.T) {
|
||||
if err := validateAttributeIDsOwned(nil, nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all owned", func(t *testing.T) {
|
||||
if err := validateAttributeIDsOwned([]uuid.UUID{a, b}, []uuid.UUID{b, a}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate request ids still ok when owned", func(t *testing.T) {
|
||||
// Ownership query returns distinct rows; duplicates are allowed through to INSERT
|
||||
// (unique constraint rejects them later — same as the old per-row path).
|
||||
if err := validateAttributeIDsOwned([]uuid.UUID{a, a}, []uuid.UUID{a}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing id", func(t *testing.T) {
|
||||
err := validateAttributeIDsOwned([]uuid.UUID{a, c}, []uuid.UUID{a})
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-tenant treated as missing", func(t *testing.T) {
|
||||
err := validateAttributeIDsOwned([]uuid.UUID{b}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func asUUID(t *testing.T, v any) uuid.UUID {
|
||||
t.Helper()
|
||||
switch x := v.(type) {
|
||||
case uuid.UUID:
|
||||
return x
|
||||
case string:
|
||||
id, err := uuid.Parse(x)
|
||||
if err != nil {
|
||||
t.Fatalf("parse uuid %q: %v", x, err)
|
||||
}
|
||||
return id
|
||||
case [16]byte:
|
||||
return uuid.UUID(x)
|
||||
default:
|
||||
t.Fatalf("unexpected uuid type %T", v)
|
||||
return uuid.Nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCategoryAttributesBatch(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "links-test-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
catUnique := "links-cat-" + companyID.String()[:8]
|
||||
if _, err := svc.CreateCategory(ctx, companyID, "Links Test Cat", catUnique, nil, nil); err != nil {
|
||||
t.Fatalf("CreateCategory: %v", err)
|
||||
}
|
||||
attrA, err := svc.CreateAttribute(ctx, companyID, "color", "Color", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute A: %v", err)
|
||||
}
|
||||
attrB, err := svc.CreateAttribute(ctx, companyID, "size", "Size", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute B: %v", err)
|
||||
}
|
||||
idA := asUUID(t, attrA["id"])
|
||||
idB := asUUID(t, attrB["id"])
|
||||
|
||||
otherCompany := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
otherCompany, "links-other-"+otherCompany.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert other company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, otherCompany)
|
||||
})
|
||||
foreign, err := svc.CreateAttribute(ctx, otherCompany, "foreign", "Foreign", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute foreign: %v", err)
|
||||
}
|
||||
foreignID := asUUID(t, foreign["id"])
|
||||
|
||||
t.Run("batch replace with required flags", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idA, idB}, map[string]bool{
|
||||
idA.String(): true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("want 2 links, got %d", len(items))
|
||||
}
|
||||
byAttr := map[uuid.UUID]bool{}
|
||||
for _, item := range items {
|
||||
aid := asUUID(t, item["attribute_id"])
|
||||
req, _ := item["required"].(bool)
|
||||
byAttr[aid] = req
|
||||
}
|
||||
if !byAttr[idA] {
|
||||
t.Fatal("attribute A should be required")
|
||||
}
|
||||
if byAttr[idB] {
|
||||
t.Fatal("attribute B should not be required")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace clears previous links", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idB}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("want 1 link, got %d", len(items))
|
||||
}
|
||||
if asUUID(t, items[0]["attribute_id"]) != idB {
|
||||
t.Fatalf("want attribute B, got %v", items[0]["attribute_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty list clears all", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("want 0 links, got %d", len(items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing attribute", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{uuid.New()}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-tenant attribute", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{foreignID}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("failed replace must leave links empty, got %d", len(items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("category not found", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, "no-such-category", []uuid.UUID{idA}, nil)
|
||||
if err == nil || err.Error() != "category not found" {
|
||||
t.Fatalf("got %v, want category not found", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestListVariablesSQLPagination(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "vars-page-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
for _, name := range []string{"alpha", "beta", "gamma"} {
|
||||
if _, err := svc.CreateVariable(ctx, companyID, name, "v", nil); err != nil {
|
||||
t.Fatalf("create variable %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
page, total, err := svc.ListVariables(ctx, companyID, ListFilter{Limit: 2, Offset: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("ListVariables: %v", err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("total=%d want 3", total)
|
||||
}
|
||||
if len(page) != 2 {
|
||||
t.Fatalf("page len=%d want 2", len(page))
|
||||
}
|
||||
if page[0]["name"] != "alpha" || page[1]["name"] != "beta" {
|
||||
t.Fatalf("unexpected order: %#v", page)
|
||||
}
|
||||
|
||||
page2, total2, err := svc.ListVariables(ctx, companyID, ListFilter{Limit: 2, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("ListVariables page2: %v", err)
|
||||
}
|
||||
if total2 != 3 || len(page2) != 1 || page2[0]["name"] != "gamma" {
|
||||
t.Fatalf("page2=%#v total=%d", page2, total2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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.
|
||||
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
|
||||
} else {
|
||||
mapped["title"] = nil
|
||||
}
|
||||
if item.Description != "" {
|
||||
mapped["description"] = item.Description
|
||||
} else {
|
||||
mapped["description"] = nil
|
||||
}
|
||||
if len(item.Specifications) > 0 {
|
||||
mapped["specifications"] = item.Specifications
|
||||
} else {
|
||||
mapped["specifications"] = []any{}
|
||||
}
|
||||
if item.Search != "" {
|
||||
mapped["search"] = item.Search
|
||||
} else {
|
||||
mapped["search"] = nil
|
||||
}
|
||||
if item.CategoryUniqueID != "" {
|
||||
mapped["category"] = item.CategoryUniqueID
|
||||
mapped["category_unique_id"] = item.CategoryUniqueID
|
||||
}
|
||||
for k, v := range mappedImageFieldsFromV1Item(item) {
|
||||
mapped[k] = v
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
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).
|
||||
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)
|
||||
img := mappedImageFieldsFromV1Item(item)
|
||||
if len(img) > 0 {
|
||||
for k, v := range img {
|
||||
merged[k] = v
|
||||
}
|
||||
if item.CategoryUniqueID != "" {
|
||||
merged["category"] = item.CategoryUniqueID
|
||||
merged["category_unique_id"] = item.CategoryUniqueID
|
||||
}
|
||||
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))
|
||||
} else if item.CategoryUniqueID != "" {
|
||||
_ = json.Unmarshal(existingMapped, &merged)
|
||||
merged["category"] = item.CategoryUniqueID
|
||||
merged["category_unique_id"] = item.CategoryUniqueID
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeGTIN(t *testing.T) {
|
||||
if got := NormalizeGTIN(" 400-599-8858394 "); got != "4005998858394" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := NormalizeGTIN("SKU-ABC"); got != "SKU-ABC" {
|
||||
t.Fatalf("non-digit fallback got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMappedDataFromV1Item(t *testing.T) {
|
||||
mapped := BuildMappedDataFromV1Item(V1ProcessItem{
|
||||
EAN: "1234567890123",
|
||||
Title: "Widget",
|
||||
Description: "A widget",
|
||||
CategoryUniqueID: "electronics",
|
||||
MainImage: "https://cdn.example.com/w.jpg",
|
||||
MoreImages: []any{"https://cdn.example.com/w2.jpg"},
|
||||
Specifications: []map[string]any{{"key": "color", "value": "red"}},
|
||||
})
|
||||
if mapped["ean"] != "1234567890123" || mapped["title"] != "Widget" {
|
||||
t.Fatalf("mapped=%v", mapped)
|
||||
}
|
||||
if mapped["category"] != "electronics" {
|
||||
t.Fatalf("category=%v", mapped["category"])
|
||||
}
|
||||
if mapped["image_url"] != "https://cdn.example.com/w.jpg" {
|
||||
t.Fatalf("image_url=%v", mapped["image_url"])
|
||||
}
|
||||
b, _ := json.Marshal(mapped["additional_image_urls"])
|
||||
if string(b) != `["https://cdn.example.com/w2.jpg"]` {
|
||||
t.Fatalf("more=%s", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const maxResetProductIDs = 5000
|
||||
|
||||
// ResetProductsToUnprocessed resets selected products so they reappear as unprocessed raw items.
|
||||
// kind "raw" updates raw_products by id; kind "processed" (default) deletes processed rows and
|
||||
// resets linked raw rows (by raw_product_id and shared product_id/gtin), matching legacy behavior.
|
||||
func (s *Service) ResetProductsToUnprocessed(ctx context.Context, companyID uuid.UUID, productIDs []uuid.UUID, kind string) (map[string]any, error) {
|
||||
if len(productIDs) == 0 {
|
||||
return nil, ClientMsg("product_ids is required")
|
||||
}
|
||||
if len(productIDs) > maxResetProductIDs {
|
||||
return nil, ClientMsg(fmt.Sprintf("at most %d product_ids allowed", maxResetProductIDs))
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var resetCount int64
|
||||
switch kind {
|
||||
case "raw":
|
||||
resetCount, err = resetRawProducts(ctx, tx, companyID, productIDs)
|
||||
default:
|
||||
resetCount, err = resetProcessedProducts(ctx, tx, companyID, productIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"reset_count": resetCount,
|
||||
"message": fmt.Sprintf("%d product(s) returned to unprocessed state", resetCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetRawProducts(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, ids []uuid.UUID) (int64, error) {
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE raw_products
|
||||
SET is_processed = false,
|
||||
processing_status = 'unprocessed',
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
DELETE FROM processed_products
|
||||
WHERE company_id = $1 AND raw_product_id = ANY($2::uuid[])`, companyID, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ct.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func resetProcessedProducts(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, ids []uuid.UUID) (int64, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, raw_product_id, product_id
|
||||
FROM processed_products
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
processedIDs := make([]uuid.UUID, 0, len(ids))
|
||||
rawIDs := make([]uuid.UUID, 0)
|
||||
gtins := make([]string, 0)
|
||||
seenGTIN := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var rawID *uuid.UUID
|
||||
var productID *string
|
||||
if err := rows.Scan(&id, &rawID, &productID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processedIDs = append(processedIDs, id)
|
||||
if rawID != nil {
|
||||
rawIDs = append(rawIDs, *rawID)
|
||||
}
|
||||
if productID != nil {
|
||||
g := *productID
|
||||
if g != "" {
|
||||
if _, ok := seenGTIN[g]; !ok {
|
||||
seenGTIN[g] = struct{}{}
|
||||
gtins = append(gtins, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(processedIDs) == 0 {
|
||||
return 0, ClientMsg("no products found to return to unprocessed state")
|
||||
}
|
||||
|
||||
if len(gtins) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE raw_products
|
||||
SET is_processed = false,
|
||||
processing_status = 'unprocessed',
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND gtin = ANY($2::text[])`, companyID, gtins)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if len(rawIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE raw_products
|
||||
SET is_processed = false,
|
||||
processing_status = 'unprocessed',
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, rawIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
ct, err := tx.Exec(ctx, `
|
||||
DELETE FROM processed_products
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, processedIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ct.RowsAffected(), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const standardFieldSelect = `
|
||||
sf.id, sf.company_id, sf.name, sf.key, sf.type, sf.group_id,
|
||||
sf.is_required, sf.description, sf.default_value, sf.is_system,
|
||||
sf.enabled, sf.unit, sf.sort_order, sf.mapping_hints,
|
||||
sf.created_at, sf.updated_at, fg.name AS group_name`
|
||||
|
||||
var standardFieldCols = []string{
|
||||
"id", "company_id", "name", "key", "type", "group_id",
|
||||
"is_required", "description", "default_value", "is_system",
|
||||
"enabled", "unit", "sort_order", "mapping_hints",
|
||||
"created_at", "updated_at", "group_name",
|
||||
}
|
||||
|
||||
// Allowed standard-field types (Shopify-aligned product field kinds).
|
||||
var standardFieldTypes = map[string]struct{}{
|
||||
"string": {}, "number": {}, "boolean": {}, "date": {},
|
||||
"url": {}, "image": {}, "dimension": {}, "weight": {},
|
||||
"color": {}, "custom": {},
|
||||
}
|
||||
|
||||
func validateStandardFieldType(typ string) error {
|
||||
typ = strings.TrimSpace(typ)
|
||||
if typ == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := standardFieldTypes[typ]; !ok {
|
||||
return ClientMsg("type must be one of: string, number, boolean, date, url, image, dimension, weight, color, custom")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListFieldGroups(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, name, description, "order", is_system, created_at, updated_at
|
||||
FROM field_groups
|
||||
WHERE company_id = $1
|
||||
ORDER BY "order" ASC, name ASC`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "company_id", "name", "description", "order", "is_system", "created_at", "updated_at"})
|
||||
}
|
||||
|
||||
func (s *Service) GetFieldGroup(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, name, description, "order", is_system, created_at, updated_at
|
||||
FROM field_groups WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, []string{"id", "company_id", "name", "description", "order", "is_system", "created_at", "updated_at"})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateFieldGroup(ctx context.Context, companyID uuid.UUID, name string, description *string, order int) (map[string]any, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ClientMsg("name required")
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO field_groups (company_id, name, description, "order")
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`, companyID, name, description, order).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFieldGroup(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFieldGroup(ctx context.Context, companyID, id uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
existing, err := s.GetFieldGroup(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return nil, ErrSystemImmutable
|
||||
}
|
||||
name := pickString(body, "name")
|
||||
desc := pickStringPtr(body, "description")
|
||||
order, hasOrder := pickInt(body, "order")
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE field_groups SET
|
||||
name = CASE WHEN $3 <> '' THEN $3 ELSE name END,
|
||||
description = CASE WHEN $4::text IS NOT NULL THEN $4 ELSE description END,
|
||||
"order" = CASE WHEN $5::boolean THEN $6 ELSE "order" END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`,
|
||||
id, companyID, name, desc, hasOrder, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFieldGroup(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFieldGroup(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
existing, err := s.GetFieldGroup(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return ErrSystemImmutable
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `DELETE FROM field_groups WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListStandardFields(ctx context.Context, companyID uuid.UUID, enabledOnly bool) ([]map[string]any, error) {
|
||||
q := `
|
||||
SELECT ` + standardFieldSelect + `
|
||||
FROM standard_fields sf
|
||||
LEFT JOIN field_groups fg ON fg.id = sf.group_id
|
||||
WHERE sf.company_id = $1`
|
||||
if enabledOnly {
|
||||
q += ` AND sf.enabled = true`
|
||||
}
|
||||
q += ` ORDER BY COALESCE(fg."order", 0), sf.sort_order ASC, sf.name ASC`
|
||||
rows, err := s.Pool.Query(ctx, q, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, standardFieldCols)
|
||||
}
|
||||
|
||||
func (s *Service) GetStandardField(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT `+standardFieldSelect+`
|
||||
FROM standard_fields sf
|
||||
LEFT JOIN field_groups fg ON fg.id = sf.group_id
|
||||
WHERE sf.id = $1 AND sf.company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, standardFieldCols)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateStandardField(ctx context.Context, companyID uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
name := strings.TrimSpace(pickString(body, "name"))
|
||||
key := strings.TrimSpace(pickString(body, "key"))
|
||||
typ := strings.TrimSpace(pickString(body, "type"))
|
||||
groupIDStr := strings.TrimSpace(pickString(body, "group_id", "groupId"))
|
||||
if name == "" || key == "" || typ == "" || groupIDStr == "" {
|
||||
return nil, ClientMsg("name, key, type, and group_id required")
|
||||
}
|
||||
if err := validateStandardFieldType(typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupID, err := uuid.Parse(groupIDStr)
|
||||
if err != nil {
|
||||
return nil, ClientMsg("invalid group_id")
|
||||
}
|
||||
if _, err := s.GetFieldGroup(ctx, companyID, groupID); err != nil {
|
||||
return nil, ClientMsg("group not found")
|
||||
}
|
||||
isRequired := pickBool(body, "is_required", "isRequired")
|
||||
enabled := true
|
||||
if v, ok := pickBoolOk(body, "enabled"); ok {
|
||||
enabled = v
|
||||
}
|
||||
desc := pickStringPtr(body, "description")
|
||||
defVal := pickStringPtr(body, "default_value", "defaultValue")
|
||||
unit := pickStringPtr(body, "unit")
|
||||
sortOrder, _ := pickInt(body, "sort_order", "sortOrder")
|
||||
hintsJSON, err := marshalMappingHints(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO standard_fields (
|
||||
company_id, name, key, type, group_id, is_required, description, default_value,
|
||||
enabled, unit, sort_order, mapping_hints
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb) RETURNING id`,
|
||||
companyID, name, key, typ, groupID, isRequired, desc, defVal,
|
||||
enabled, unit, sortOrder, hintsJSON).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStandardField(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStandardField(ctx context.Context, companyID, id uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
existing, err := s.GetStandardField(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
system := isTruthy(existing["is_system"])
|
||||
|
||||
// System fields keep identity immutable; config (enabled/unit/defaults/hints) stays editable.
|
||||
name := ""
|
||||
key := ""
|
||||
typ := ""
|
||||
var groupID *uuid.UUID
|
||||
if !system {
|
||||
name = strings.TrimSpace(pickString(body, "name"))
|
||||
key = strings.TrimSpace(pickString(body, "key"))
|
||||
typ = strings.TrimSpace(pickString(body, "type"))
|
||||
if err := validateStandardFieldType(typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupIDStr := strings.TrimSpace(pickString(body, "group_id", "groupId"))
|
||||
if groupIDStr != "" {
|
||||
parsed, err := uuid.Parse(groupIDStr)
|
||||
if err != nil {
|
||||
return nil, ClientMsg("invalid group_id")
|
||||
}
|
||||
if _, err := s.GetFieldGroup(ctx, companyID, parsed); err != nil {
|
||||
return nil, ClientMsg("group not found")
|
||||
}
|
||||
groupID = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
isRequired, hasRequired := pickBoolOk(body, "is_required", "isRequired")
|
||||
enabled, hasEnabled := pickBoolOk(body, "enabled")
|
||||
desc := pickStringPtr(body, "description")
|
||||
defVal := pickStringPtr(body, "default_value", "defaultValue")
|
||||
unit := pickStringPtr(body, "unit")
|
||||
sortOrder, hasSort := pickInt(body, "sort_order", "sortOrder")
|
||||
hintsJSON, hasHints, err := marshalMappingHintsOk(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET
|
||||
name = CASE WHEN $3 <> '' THEN $3 ELSE name END,
|
||||
key = CASE WHEN $4 <> '' THEN $4 ELSE key END,
|
||||
type = CASE WHEN $5 <> '' THEN $5 ELSE type END,
|
||||
group_id = COALESCE($6, group_id),
|
||||
is_required = CASE WHEN $7::boolean THEN $8 ELSE is_required END,
|
||||
description = CASE WHEN $9::text IS NOT NULL THEN $9 ELSE description END,
|
||||
default_value = CASE WHEN $10::text IS NOT NULL THEN $10 ELSE default_value END,
|
||||
enabled = CASE WHEN $11::boolean THEN $12 ELSE enabled END,
|
||||
unit = CASE WHEN $13::text IS NOT NULL THEN $13 ELSE unit END,
|
||||
sort_order = CASE WHEN $14::boolean THEN $15 ELSE sort_order END,
|
||||
mapping_hints = CASE WHEN $16::boolean THEN $17::jsonb ELSE mapping_hints END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`,
|
||||
id, companyID, name, key, typ, groupID,
|
||||
hasRequired, isRequired, desc, defVal,
|
||||
hasEnabled, enabled, unit, hasSort, sortOrder,
|
||||
hasHints, hintsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStandardField(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStandardField(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
existing, err := s.GetStandardField(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return ErrSystemImmutable
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `DELETE FROM standard_fields WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetStandardFieldsEnabled toggles enabled for the given field IDs (any fields, including system).
|
||||
func (s *Service) BulkSetStandardFieldsEnabled(ctx context.Context, companyID uuid.UUID, ids []uuid.UUID, enabled bool) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, ClientMsg("ids required")
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = $3, updated_at = now()
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, ids, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ct.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// EnableRecommendedEcommerce ensures the ecommerce catalog exists and enables the recommended set.
|
||||
func (s *Service) EnableRecommendedEcommerce(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
if err := s.EnsureEcommerceCatalog(ctx, companyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := recommendedEcommerceKeys()
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = true, updated_at = now()
|
||||
WHERE company_id = $1 AND key = ANY($2::text[])`, companyID, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Also enable every catalog field that ships Enabled:true in the ecommerce seed
|
||||
// so mapping/forms see the full legacy-compatible set after migration gaps.
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = true, updated_at = now()
|
||||
WHERE company_id = $1 AND is_system = true AND enabled = false`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListStandardFields(ctx, companyID, false)
|
||||
}
|
||||
|
||||
func (s *Service) ListStructuredDescriptions(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, field_key, type, created_at, updated_at
|
||||
FROM structured_description_fields
|
||||
WHERE company_id = $1
|
||||
ORDER BY field_key`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "company_id", "field_key", "type", "created_at", "updated_at"})
|
||||
}
|
||||
|
||||
func (s *Service) GetStructuredDescription(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, field_key, type, created_at, updated_at
|
||||
FROM structured_description_fields
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, []string{"id", "company_id", "field_key", "type", "created_at", "updated_at"})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateStructuredDescription(ctx context.Context, companyID uuid.UUID, fieldKey, typ string) (map[string]any, error) {
|
||||
fieldKey = strings.TrimSpace(fieldKey)
|
||||
typ = strings.TrimSpace(typ)
|
||||
if fieldKey == "" {
|
||||
return nil, ClientMsg("field_key required")
|
||||
}
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO structured_description_fields (company_id, field_key, type)
|
||||
VALUES ($1, $2, $3) RETURNING id`, companyID, fieldKey, typ).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStructuredDescription(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStructuredDescription(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM structured_description_fields WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalMappingHints(body map[string]any) (string, error) {
|
||||
s, ok, err := marshalMappingHintsOk(body)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
if !ok {
|
||||
return "[]", nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func marshalMappingHintsOk(body map[string]any) (string, bool, error) {
|
||||
raw, ok := body["mapping_hints"]
|
||||
if !ok {
|
||||
raw, ok = body["mappingHints"]
|
||||
}
|
||||
if !ok {
|
||||
return "[]", false, nil
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case nil:
|
||||
return "[]", true, nil
|
||||
case string:
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return "[]", true, nil
|
||||
}
|
||||
var probe any
|
||||
if err := json.Unmarshal([]byte(t), &probe); err != nil {
|
||||
return "", false, ClientMsg("mapping_hints must be JSON array")
|
||||
}
|
||||
return t, true, nil
|
||||
default:
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", false, ClientMsg("invalid mapping_hints")
|
||||
}
|
||||
return string(b), true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func pickString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickStringPtr(m map[string]any, keys ...string) *string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if v == nil {
|
||||
empty := ""
|
||||
return &empty
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickBool(m map[string]any, keys ...string) bool {
|
||||
b, _ := pickBoolOk(m, keys...)
|
||||
return b
|
||||
}
|
||||
|
||||
func pickBoolOk(m map[string]any, keys ...string) (bool, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func pickInt(m map[string]any, keys ...string) (int, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isTruthy(v any) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
Reference in New Issue
Block a user