Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const standardFieldSelect = `
|
||||
sf.id, sf.company_id, sf.name, sf.key, sf.type, sf.group_id,
|
||||
sf.is_required, sf.description, sf.default_value, sf.is_system,
|
||||
sf.enabled, sf.unit, sf.sort_order, sf.mapping_hints,
|
||||
sf.created_at, sf.updated_at, fg.name AS group_name`
|
||||
|
||||
var standardFieldCols = []string{
|
||||
"id", "company_id", "name", "key", "type", "group_id",
|
||||
"is_required", "description", "default_value", "is_system",
|
||||
"enabled", "unit", "sort_order", "mapping_hints",
|
||||
"created_at", "updated_at", "group_name",
|
||||
}
|
||||
|
||||
// Allowed standard-field types (Shopify-aligned product field kinds).
|
||||
var standardFieldTypes = map[string]struct{}{
|
||||
"string": {}, "number": {}, "boolean": {}, "date": {},
|
||||
"url": {}, "image": {}, "dimension": {}, "weight": {},
|
||||
"color": {}, "custom": {},
|
||||
}
|
||||
|
||||
func validateStandardFieldType(typ string) error {
|
||||
typ = strings.TrimSpace(typ)
|
||||
if typ == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := standardFieldTypes[typ]; !ok {
|
||||
return ClientMsg("type must be one of: string, number, boolean, date, url, image, dimension, weight, color, custom")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListFieldGroups(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, name, description, "order", is_system, created_at, updated_at
|
||||
FROM field_groups
|
||||
WHERE company_id = $1
|
||||
ORDER BY "order" ASC, name ASC`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "company_id", "name", "description", "order", "is_system", "created_at", "updated_at"})
|
||||
}
|
||||
|
||||
func (s *Service) GetFieldGroup(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, name, description, "order", is_system, created_at, updated_at
|
||||
FROM field_groups WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, []string{"id", "company_id", "name", "description", "order", "is_system", "created_at", "updated_at"})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateFieldGroup(ctx context.Context, companyID uuid.UUID, name string, description *string, order int) (map[string]any, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ClientMsg("name required")
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO field_groups (company_id, name, description, "order")
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`, companyID, name, description, order).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFieldGroup(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFieldGroup(ctx context.Context, companyID, id uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
existing, err := s.GetFieldGroup(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return nil, ErrSystemImmutable
|
||||
}
|
||||
name := pickString(body, "name")
|
||||
desc := pickStringPtr(body, "description")
|
||||
order, hasOrder := pickInt(body, "order")
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE field_groups SET
|
||||
name = CASE WHEN $3 <> '' THEN $3 ELSE name END,
|
||||
description = CASE WHEN $4::text IS NOT NULL THEN $4 ELSE description END,
|
||||
"order" = CASE WHEN $5::boolean THEN $6 ELSE "order" END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`,
|
||||
id, companyID, name, desc, hasOrder, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFieldGroup(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFieldGroup(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
existing, err := s.GetFieldGroup(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return ErrSystemImmutable
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `DELETE FROM field_groups WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListStandardFields(ctx context.Context, companyID uuid.UUID, enabledOnly bool) ([]map[string]any, error) {
|
||||
q := `
|
||||
SELECT ` + standardFieldSelect + `
|
||||
FROM standard_fields sf
|
||||
LEFT JOIN field_groups fg ON fg.id = sf.group_id
|
||||
WHERE sf.company_id = $1`
|
||||
if enabledOnly {
|
||||
q += ` AND sf.enabled = true`
|
||||
}
|
||||
q += ` ORDER BY COALESCE(fg."order", 0), sf.sort_order ASC, sf.name ASC`
|
||||
rows, err := s.Pool.Query(ctx, q, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, standardFieldCols)
|
||||
}
|
||||
|
||||
func (s *Service) GetStandardField(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT `+standardFieldSelect+`
|
||||
FROM standard_fields sf
|
||||
LEFT JOIN field_groups fg ON fg.id = sf.group_id
|
||||
WHERE sf.id = $1 AND sf.company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, standardFieldCols)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateStandardField(ctx context.Context, companyID uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
name := strings.TrimSpace(pickString(body, "name"))
|
||||
key := strings.TrimSpace(pickString(body, "key"))
|
||||
typ := strings.TrimSpace(pickString(body, "type"))
|
||||
groupIDStr := strings.TrimSpace(pickString(body, "group_id", "groupId"))
|
||||
if name == "" || key == "" || typ == "" || groupIDStr == "" {
|
||||
return nil, ClientMsg("name, key, type, and group_id required")
|
||||
}
|
||||
if err := validateStandardFieldType(typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupID, err := uuid.Parse(groupIDStr)
|
||||
if err != nil {
|
||||
return nil, ClientMsg("invalid group_id")
|
||||
}
|
||||
if _, err := s.GetFieldGroup(ctx, companyID, groupID); err != nil {
|
||||
return nil, ClientMsg("group not found")
|
||||
}
|
||||
isRequired := pickBool(body, "is_required", "isRequired")
|
||||
enabled := true
|
||||
if v, ok := pickBoolOk(body, "enabled"); ok {
|
||||
enabled = v
|
||||
}
|
||||
desc := pickStringPtr(body, "description")
|
||||
defVal := pickStringPtr(body, "default_value", "defaultValue")
|
||||
unit := pickStringPtr(body, "unit")
|
||||
sortOrder, _ := pickInt(body, "sort_order", "sortOrder")
|
||||
hintsJSON, err := marshalMappingHints(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO standard_fields (
|
||||
company_id, name, key, type, group_id, is_required, description, default_value,
|
||||
enabled, unit, sort_order, mapping_hints
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb) RETURNING id`,
|
||||
companyID, name, key, typ, groupID, isRequired, desc, defVal,
|
||||
enabled, unit, sortOrder, hintsJSON).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStandardField(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStandardField(ctx context.Context, companyID, id uuid.UUID, body map[string]any) (map[string]any, error) {
|
||||
existing, err := s.GetStandardField(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
system := isTruthy(existing["is_system"])
|
||||
|
||||
// System fields keep identity immutable; config (enabled/unit/defaults/hints) stays editable.
|
||||
name := ""
|
||||
key := ""
|
||||
typ := ""
|
||||
var groupID *uuid.UUID
|
||||
if !system {
|
||||
name = strings.TrimSpace(pickString(body, "name"))
|
||||
key = strings.TrimSpace(pickString(body, "key"))
|
||||
typ = strings.TrimSpace(pickString(body, "type"))
|
||||
if err := validateStandardFieldType(typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupIDStr := strings.TrimSpace(pickString(body, "group_id", "groupId"))
|
||||
if groupIDStr != "" {
|
||||
parsed, err := uuid.Parse(groupIDStr)
|
||||
if err != nil {
|
||||
return nil, ClientMsg("invalid group_id")
|
||||
}
|
||||
if _, err := s.GetFieldGroup(ctx, companyID, parsed); err != nil {
|
||||
return nil, ClientMsg("group not found")
|
||||
}
|
||||
groupID = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
isRequired, hasRequired := pickBoolOk(body, "is_required", "isRequired")
|
||||
enabled, hasEnabled := pickBoolOk(body, "enabled")
|
||||
desc := pickStringPtr(body, "description")
|
||||
defVal := pickStringPtr(body, "default_value", "defaultValue")
|
||||
unit := pickStringPtr(body, "unit")
|
||||
sortOrder, hasSort := pickInt(body, "sort_order", "sortOrder")
|
||||
hintsJSON, hasHints, err := marshalMappingHintsOk(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET
|
||||
name = CASE WHEN $3 <> '' THEN $3 ELSE name END,
|
||||
key = CASE WHEN $4 <> '' THEN $4 ELSE key END,
|
||||
type = CASE WHEN $5 <> '' THEN $5 ELSE type END,
|
||||
group_id = COALESCE($6, group_id),
|
||||
is_required = CASE WHEN $7::boolean THEN $8 ELSE is_required END,
|
||||
description = CASE WHEN $9::text IS NOT NULL THEN $9 ELSE description END,
|
||||
default_value = CASE WHEN $10::text IS NOT NULL THEN $10 ELSE default_value END,
|
||||
enabled = CASE WHEN $11::boolean THEN $12 ELSE enabled END,
|
||||
unit = CASE WHEN $13::text IS NOT NULL THEN $13 ELSE unit END,
|
||||
sort_order = CASE WHEN $14::boolean THEN $15 ELSE sort_order END,
|
||||
mapping_hints = CASE WHEN $16::boolean THEN $17::jsonb ELSE mapping_hints END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`,
|
||||
id, companyID, name, key, typ, groupID,
|
||||
hasRequired, isRequired, desc, defVal,
|
||||
hasEnabled, enabled, unit, hasSort, sortOrder,
|
||||
hasHints, hintsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStandardField(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStandardField(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
existing, err := s.GetStandardField(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isTruthy(existing["is_system"]) {
|
||||
return ErrSystemImmutable
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `DELETE FROM standard_fields WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetStandardFieldsEnabled toggles enabled for the given field IDs (any fields, including system).
|
||||
func (s *Service) BulkSetStandardFieldsEnabled(ctx context.Context, companyID uuid.UUID, ids []uuid.UUID, enabled bool) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, ClientMsg("ids required")
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = $3, updated_at = now()
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, ids, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ct.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// EnableRecommendedEcommerce ensures the ecommerce catalog exists and enables the recommended set.
|
||||
func (s *Service) EnableRecommendedEcommerce(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
if err := s.EnsureEcommerceCatalog(ctx, companyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := recommendedEcommerceKeys()
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = true, updated_at = now()
|
||||
WHERE company_id = $1 AND key = ANY($2::text[])`, companyID, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Also enable every catalog field that ships Enabled:true in the ecommerce seed
|
||||
// so mapping/forms see the full legacy-compatible set after migration gaps.
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE standard_fields SET enabled = true, updated_at = now()
|
||||
WHERE company_id = $1 AND is_system = true AND enabled = false`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListStandardFields(ctx, companyID, false)
|
||||
}
|
||||
|
||||
func (s *Service) ListStructuredDescriptions(ctx context.Context, companyID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, field_key, type, created_at, updated_at
|
||||
FROM structured_description_fields
|
||||
WHERE company_id = $1
|
||||
ORDER BY field_key`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "company_id", "field_key", "type", "created_at", "updated_at"})
|
||||
}
|
||||
|
||||
func (s *Service) GetStructuredDescription(ctx context.Context, companyID, id uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, field_key, type, created_at, updated_at
|
||||
FROM structured_description_fields
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
item, err := scanMap(row, []string{"id", "company_id", "field_key", "type", "created_at", "updated_at"})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateStructuredDescription(ctx context.Context, companyID uuid.UUID, fieldKey, typ string) (map[string]any, error) {
|
||||
fieldKey = strings.TrimSpace(fieldKey)
|
||||
typ = strings.TrimSpace(typ)
|
||||
if fieldKey == "" {
|
||||
return nil, ClientMsg("field_key required")
|
||||
}
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO structured_description_fields (company_id, field_key, type)
|
||||
VALUES ($1, $2, $3) RETURNING id`, companyID, fieldKey, typ).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetStructuredDescription(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStructuredDescription(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM structured_description_fields WHERE id = $1 AND company_id = $2`, id, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalMappingHints(body map[string]any) (string, error) {
|
||||
s, ok, err := marshalMappingHintsOk(body)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
if !ok {
|
||||
return "[]", nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func marshalMappingHintsOk(body map[string]any) (string, bool, error) {
|
||||
raw, ok := body["mapping_hints"]
|
||||
if !ok {
|
||||
raw, ok = body["mappingHints"]
|
||||
}
|
||||
if !ok {
|
||||
return "[]", false, nil
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case nil:
|
||||
return "[]", true, nil
|
||||
case string:
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return "[]", true, nil
|
||||
}
|
||||
var probe any
|
||||
if err := json.Unmarshal([]byte(t), &probe); err != nil {
|
||||
return "", false, ClientMsg("mapping_hints must be JSON array")
|
||||
}
|
||||
return t, true, nil
|
||||
default:
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", false, ClientMsg("invalid mapping_hints")
|
||||
}
|
||||
return string(b), true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func pickString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickStringPtr(m map[string]any, keys ...string) *string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if v == nil {
|
||||
empty := ""
|
||||
return &empty
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickBool(m map[string]any, keys ...string) bool {
|
||||
b, _ := pickBoolOk(m, keys...)
|
||||
return b
|
||||
}
|
||||
|
||||
func pickBoolOk(m map[string]any, keys ...string) (bool, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func pickInt(m map[string]any, keys ...string) (int, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isTruthy(v any) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
Reference in New Issue
Block a user