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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+370
View File
@@ -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
}
+62
View File
@@ -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
}
+39
View File
@@ -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
}
}
+273
View File
@@ -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")
}
}
+414
View File
@@ -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"))
}
}
+830
View File
@@ -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, &gtin); 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, &gtin); 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")
}
}
+179
View File
@@ -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
}
+213
View File
@@ -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)
}
}
+361
View File
@@ -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
}
+40
View File
@@ -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)
}
}
+141
View File
@@ -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
}