Files

483 lines
15 KiB
Go
Raw Permalink Normal View History

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
}