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 }