450 lines
15 KiB
Go
450 lines
15 KiB
Go
package woocommerce
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrNotConfigured = errors.New("woocommerce not configured")
|
||
|
|
ErrNotEnabled = errors.New("woocommerce sync is disabled")
|
||
|
|
ErrMissingCreds = errors.New("woocommerce credentials missing")
|
||
|
|
ErrInvalidSyncScope = errors.New("invalid product sync scope")
|
||
|
|
ErrInvalidScheduleInterval = errors.New("schedule_interval_hours must be between 0 and 168")
|
||
|
|
)
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
Pool *pgxpool.Pool
|
||
|
|
Key []byte
|
||
|
|
HTTPClient *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
type Config struct {
|
||
|
|
StoreURL string `json:"store_url"`
|
||
|
|
IsEnabled bool `json:"is_enabled"`
|
||
|
|
Configured bool `json:"configured"`
|
||
|
|
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
||
|
|
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||
|
|
LastTestStatus *string `json:"last_test_status,omitempty"`
|
||
|
|
HasCredentials bool `json:"has_credentials"`
|
||
|
|
PendingSync bool `json:"pending_sync"`
|
||
|
|
PendingOrdersSync bool `json:"pending_orders_sync"`
|
||
|
|
PendingReviewsSync bool `json:"pending_reviews_sync"`
|
||
|
|
MatchStrategy string `json:"match_strategy"`
|
||
|
|
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||
|
|
LastSyncError string `json:"last_sync_error,omitempty"`
|
||
|
|
LastSyncSummary *SyncSummary `json:"last_sync_summary,omitempty"`
|
||
|
|
ProductMapCount int `json:"product_map_count"`
|
||
|
|
SyncLimit int `json:"sync_limit,omitempty"`
|
||
|
|
LastOrdersSyncedAt *time.Time `json:"last_orders_synced_at,omitempty"`
|
||
|
|
LastOrdersSyncStatus string `json:"last_orders_sync_status,omitempty"`
|
||
|
|
LastOrdersSyncError string `json:"last_orders_sync_error,omitempty"`
|
||
|
|
LastReviewsSyncedAt *time.Time `json:"last_reviews_synced_at,omitempty"`
|
||
|
|
LastReviewsSyncStatus string `json:"last_reviews_sync_status,omitempty"`
|
||
|
|
LastReviewsSyncError string `json:"last_reviews_sync_error,omitempty"`
|
||
|
|
CategoryMaps int `json:"category_map_count"`
|
||
|
|
AttributeMaps int `json:"attribute_map_count"`
|
||
|
|
ScheduleIntervalHours int `json:"schedule_interval_hours"`
|
||
|
|
SchedulePaused bool `json:"schedule_paused"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewService(pool *pgxpool.Pool, key []byte) *Service {
|
||
|
|
return &Service{
|
||
|
|
Pool: pool,
|
||
|
|
Key: key,
|
||
|
|
// Dial-time SSRF; loopback allowed for local mock Woo only (NormalizeStoreURL
|
||
|
|
// already requires https except localhost).
|
||
|
|
HTTPClient: security.SafeHTTPClient(defaultTimeout, true),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type storedConfig struct {
|
||
|
|
storeURL, keyEnc, secretEnc string
|
||
|
|
enabled bool
|
||
|
|
syncOptions []byte
|
||
|
|
lastSync, lastTest *time.Time
|
||
|
|
status *string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (storedConfig, error) {
|
||
|
|
var sc storedConfig
|
||
|
|
err := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT store_url, consumer_key, consumer_secret, is_enabled, sync_options, last_synced_at, last_test_at, last_test_status
|
||
|
|
FROM woocommerce_configs WHERE company_id = $1`, companyID).Scan(
|
||
|
|
&sc.storeURL, &sc.keyEnc, &sc.secretEnc, &sc.enabled, &sc.syncOptions, &sc.lastSync, &sc.lastTest, &sc.status,
|
||
|
|
)
|
||
|
|
return sc, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (Config, error) {
|
||
|
|
sc, err := s.loadStored(ctx, companyID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(sc.syncOptions)
|
||
|
|
key, _ := DecryptSecret(s.Key, sc.keyEnc)
|
||
|
|
secret, _ := DecryptSecret(s.Key, sc.secretEnc)
|
||
|
|
return Config{
|
||
|
|
StoreURL: sc.storeURL,
|
||
|
|
IsEnabled: sc.enabled,
|
||
|
|
Configured: true,
|
||
|
|
LastSyncedAt: sc.lastSync,
|
||
|
|
LastTestAt: sc.lastTest,
|
||
|
|
LastTestStatus: sc.status,
|
||
|
|
HasCredentials: key != "" && secret != "",
|
||
|
|
PendingSync: opt.PendingSync,
|
||
|
|
PendingOrdersSync: opt.PendingOrdersSync,
|
||
|
|
PendingReviewsSync: opt.PendingReviewsSync,
|
||
|
|
MatchStrategy: opt.MatchStrategy,
|
||
|
|
LastSyncStatus: opt.LastSyncStatus,
|
||
|
|
LastSyncError: opt.LastSyncError,
|
||
|
|
LastSyncSummary: opt.LastSyncSummary,
|
||
|
|
ProductMapCount: len(opt.ProductIDs),
|
||
|
|
SyncLimit: opt.SyncLimit,
|
||
|
|
LastOrdersSyncedAt: opt.LastOrdersSyncedAt,
|
||
|
|
LastOrdersSyncStatus: opt.LastOrdersSyncStatus,
|
||
|
|
LastOrdersSyncError: opt.LastOrdersSyncError,
|
||
|
|
LastReviewsSyncedAt: opt.LastReviewsSyncedAt,
|
||
|
|
LastReviewsSyncStatus: opt.LastReviewsSyncStatus,
|
||
|
|
LastReviewsSyncError: opt.LastReviewsSyncError,
|
||
|
|
CategoryMaps: len(opt.CategoryMappings),
|
||
|
|
AttributeMaps: len(opt.AttributeMappings),
|
||
|
|
ScheduleIntervalHours: opt.ScheduleIntervalHours,
|
||
|
|
SchedulePaused: opt.SchedulePaused,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, storeURL, key, secret string, enabled bool) (Config, error) {
|
||
|
|
normalized, err := NormalizeStoreURL(storeURL)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
keyEnc := ""
|
||
|
|
secretEnc := ""
|
||
|
|
if strings.TrimSpace(key) != "" {
|
||
|
|
keyEnc, err = EncryptSecret(s.Key, strings.TrimSpace(key))
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(secret) != "" {
|
||
|
|
secretEnc, err = EncryptSecret(s.Key, strings.TrimSpace(secret))
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_, err = s.Pool.Exec(ctx, `
|
||
|
|
INSERT INTO woocommerce_configs (company_id, store_url, consumer_key, consumer_secret, is_enabled, updated_at)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, now())
|
||
|
|
ON CONFLICT (company_id) DO UPDATE SET
|
||
|
|
store_url = EXCLUDED.store_url,
|
||
|
|
consumer_key = CASE WHEN EXCLUDED.consumer_key <> '' THEN EXCLUDED.consumer_key ELSE woocommerce_configs.consumer_key END,
|
||
|
|
consumer_secret = CASE WHEN EXCLUDED.consumer_secret <> '' THEN EXCLUDED.consumer_secret ELSE woocommerce_configs.consumer_secret END,
|
||
|
|
is_enabled = EXCLUDED.is_enabled,
|
||
|
|
updated_at = now()`,
|
||
|
|
companyID, normalized, keyEnc, secretEnc, enabled)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
return s.GetConfig(ctx, companyID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) UpdateMaps(ctx context.Context, companyID uuid.UUID, categoryMaps map[string]CategoryMap, attributeMaps map[string]AttributeMap, matchStrategy string) (Config, error) {
|
||
|
|
sc, err := s.loadStored(ctx, companyID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Config{}, ErrNotConfigured
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(sc.syncOptions)
|
||
|
|
if categoryMaps != nil {
|
||
|
|
opt.CategoryMappings = categoryMaps
|
||
|
|
}
|
||
|
|
if attributeMaps != nil {
|
||
|
|
opt.AttributeMappings = attributeMaps
|
||
|
|
}
|
||
|
|
if matchStrategy != "" {
|
||
|
|
opt.MatchStrategy = matchStrategy
|
||
|
|
}
|
||
|
|
raw, err := json.Marshal(opt)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
_, err = s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET sync_options = $2, updated_at = now() WHERE company_id = $1`,
|
||
|
|
companyID, raw)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
return s.GetConfig(ctx, companyID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateSchedule sets auto product-sync interval hours and pause flag on sync_options.
|
||
|
|
// hours 0 means the worker default (6h). Manual sync remains available when paused.
|
||
|
|
func (s *Service) UpdateSchedule(ctx context.Context, companyID uuid.UUID, hours int, paused bool) (Config, error) {
|
||
|
|
if hours < 0 || hours > maxScheduleIntervalH {
|
||
|
|
return Config{}, ErrInvalidScheduleInterval
|
||
|
|
}
|
||
|
|
sc, err := s.loadStored(ctx, companyID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Config{}, ErrNotConfigured
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(sc.syncOptions)
|
||
|
|
opt.ScheduleIntervalHours = hours
|
||
|
|
opt.SchedulePaused = paused
|
||
|
|
raw, err := json.Marshal(opt)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
_, err = s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET sync_options = $2, updated_at = now() WHERE company_id = $1`,
|
||
|
|
companyID, raw)
|
||
|
|
if err != nil {
|
||
|
|
return Config{}, err
|
||
|
|
}
|
||
|
|
return s.GetConfig(ctx, companyID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) clientFor(ctx context.Context, companyID uuid.UUID) (*Client, storedConfig, SyncOptions, error) {
|
||
|
|
sc, err := s.loadStored(ctx, companyID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return nil, sc, SyncOptions{}, ErrNotConfigured
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, sc, SyncOptions{}, err
|
||
|
|
}
|
||
|
|
key, err := DecryptSecret(s.Key, sc.keyEnc)
|
||
|
|
if err != nil {
|
||
|
|
return nil, sc, SyncOptions{}, err
|
||
|
|
}
|
||
|
|
secret, err := DecryptSecret(s.Key, sc.secretEnc)
|
||
|
|
if err != nil {
|
||
|
|
return nil, sc, SyncOptions{}, err
|
||
|
|
}
|
||
|
|
if sc.storeURL == "" || key == "" || secret == "" {
|
||
|
|
return nil, sc, SyncOptions{}, ErrMissingCreds
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(sc.syncOptions)
|
||
|
|
return NewClient(sc.storeURL, key, secret, s.HTTPClient), sc, opt, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||
|
|
client, _, _, err := s.clientFor(ctx, companyID)
|
||
|
|
status := "ok"
|
||
|
|
message := "connection successful"
|
||
|
|
if err != nil {
|
||
|
|
status = "failed"
|
||
|
|
message = err.Error()
|
||
|
|
_, _ = s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||
|
|
WHERE company_id = $1`, companyID, status)
|
||
|
|
return map[string]any{"status": status, "message": message}, err
|
||
|
|
}
|
||
|
|
if err := client.TestConnection(ctx); err != nil {
|
||
|
|
status = "failed"
|
||
|
|
message = "connection failed"
|
||
|
|
_, execErr := s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||
|
|
WHERE company_id = $1`, companyID, status)
|
||
|
|
if execErr != nil {
|
||
|
|
return map[string]any{"status": status, "message": message}, execErr
|
||
|
|
}
|
||
|
|
return map[string]any{"status": status, "message": message}, err
|
||
|
|
}
|
||
|
|
if _, err := s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||
|
|
WHERE company_id = $1`, companyID, status); err != nil {
|
||
|
|
return map[string]any{"status": status, "message": message}, err
|
||
|
|
}
|
||
|
|
return map[string]any{"status": status, "message": message}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// EnqueueSync marks the company for worker pickup (idempotent).
|
||
|
|
// Optional scopes[0] selects which products to push (status/category/limit/ids).
|
||
|
|
func (s *Service) EnqueueSync(ctx context.Context, companyID uuid.UUID, scopes ...ProductSyncScope) (map[string]any, error) {
|
||
|
|
sc, err := s.loadStored(ctx, companyID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return nil, ErrNotConfigured
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !sc.enabled {
|
||
|
|
return nil, ErrNotEnabled
|
||
|
|
}
|
||
|
|
key, err := DecryptSecret(s.Key, sc.keyEnc)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
secret, err := DecryptSecret(s.Key, sc.secretEnc)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if sc.storeURL == "" || key == "" || secret == "" {
|
||
|
|
return nil, ErrMissingCreds
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(sc.syncOptions)
|
||
|
|
if len(scopes) > 0 {
|
||
|
|
if err := applyProductSyncScope(&opt, scopes[0]); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
clearOneShotSyncFilters(&opt)
|
||
|
|
}
|
||
|
|
opt.PendingSync = true
|
||
|
|
raw, err := json.Marshal(opt)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
ct, err := s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs SET sync_options = $2, updated_at = now()
|
||
|
|
WHERE company_id = $1 AND is_enabled = true`, companyID, raw)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if ct.RowsAffected() == 0 {
|
||
|
|
return nil, ErrNotEnabled
|
||
|
|
}
|
||
|
|
return map[string]any{
|
||
|
|
"status": "accepted",
|
||
|
|
"message": "woocommerce sync queued",
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClaimNextPending claims one enabled company with pending product sync for the worker.
|
||
|
|
func (s *Service) ClaimNextPending(ctx context.Context) (uuid.UUID, error) {
|
||
|
|
var companyID uuid.UUID
|
||
|
|
err := s.Pool.QueryRow(ctx, `
|
||
|
|
UPDATE woocommerce_configs c
|
||
|
|
SET sync_options = jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_sync}', 'false'::jsonb, true),
|
||
|
|
updated_at = now()
|
||
|
|
WHERE c.company_id = (
|
||
|
|
SELECT company_id FROM woocommerce_configs
|
||
|
|
WHERE is_enabled = true
|
||
|
|
AND COALESCE(sync_options->>'pending_sync', 'false') = 'true'
|
||
|
|
ORDER BY updated_at ASC
|
||
|
|
LIMIT 1
|
||
|
|
FOR UPDATE SKIP LOCKED
|
||
|
|
)
|
||
|
|
RETURNING c.company_id`).Scan(&companyID)
|
||
|
|
return companyID, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// EnqueueDueScheduled marks enabled Woo configs as pending when last_synced_at is older
|
||
|
|
// than schedule_interval_hours (or defaultInterval). Safe/idempotent; worker ClaimNextPending drains.
|
||
|
|
func (s *Service) EnqueueDueScheduled(ctx context.Context, defaultInterval time.Duration) (int, error) {
|
||
|
|
rows, err := s.Pool.Query(ctx, `
|
||
|
|
SELECT company_id, sync_options, last_synced_at
|
||
|
|
FROM woocommerce_configs
|
||
|
|
WHERE is_enabled = true
|
||
|
|
AND COALESCE(sync_options->>'pending_sync', 'false') <> 'true'`)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
n := 0
|
||
|
|
now := time.Now().UTC()
|
||
|
|
for rows.Next() {
|
||
|
|
var companyID uuid.UUID
|
||
|
|
var raw []byte
|
||
|
|
var last *time.Time
|
||
|
|
if err := rows.Scan(&companyID, &raw, &last); err != nil {
|
||
|
|
return n, err
|
||
|
|
}
|
||
|
|
opt := parseSyncOptions(raw)
|
||
|
|
if !shouldEnqueueScheduled(opt.SchedulePaused, opt.ScheduleIntervalHours, last, now, defaultInterval) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, err := s.EnqueueSync(ctx, companyID); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
n++
|
||
|
|
}
|
||
|
|
return n, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// SyncCompany pushes processed products to WooCommerce in batches.
|
||
|
|
func (s *Service) SyncCompany(ctx context.Context, companyID uuid.UUID) (SyncSummary, error) {
|
||
|
|
client, _, opt, err := s.clientFor(ctx, companyID)
|
||
|
|
if err != nil {
|
||
|
|
return SyncSummary{}, err
|
||
|
|
}
|
||
|
|
rows, err := s.loadProductsForSync(ctx, companyID, opt)
|
||
|
|
if err != nil {
|
||
|
|
return SyncSummary{}, err
|
||
|
|
}
|
||
|
|
summary, err := s.pushProducts(ctx, client, companyID, &opt, rows)
|
||
|
|
if err != nil {
|
||
|
|
return summary, err
|
||
|
|
}
|
||
|
|
if summary.Failed == 0 {
|
||
|
|
opt.LastSyncStatus = "success"
|
||
|
|
opt.LastSyncError = ""
|
||
|
|
} else if summary.Created+summary.Updated == 0 {
|
||
|
|
opt.LastSyncStatus = "failed"
|
||
|
|
opt.LastSyncError = "all product pushes failed"
|
||
|
|
} else {
|
||
|
|
opt.LastSyncStatus = "partial"
|
||
|
|
opt.LastSyncError = "some product pushes failed"
|
||
|
|
}
|
||
|
|
sumCopy := summary
|
||
|
|
opt.LastSyncSummary = &sumCopy
|
||
|
|
opt.PendingSync = false
|
||
|
|
clearOneShotSyncFilters(&opt)
|
||
|
|
pruneStringIntMap(opt.ProductIDs, maxProductIDMap)
|
||
|
|
raw, err := json.Marshal(opt)
|
||
|
|
if err != nil {
|
||
|
|
return summary, err
|
||
|
|
}
|
||
|
|
_, err = s.Pool.Exec(ctx, `
|
||
|
|
UPDATE woocommerce_configs
|
||
|
|
SET sync_options = $2, last_synced_at = now(), updated_at = now()
|
||
|
|
WHERE company_id = $1`, companyID, raw)
|
||
|
|
if err != nil {
|
||
|
|
return summary, err
|
||
|
|
}
|
||
|
|
return summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// FetchRemoteMaps loads categories/attributes from Woo for mapping UI helpers.
|
||
|
|
func (s *Service) FetchRemoteMaps(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||
|
|
client, _, _, err := s.clientFor(ctx, companyID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
cats, err := client.ListCategories(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
attrs, err := client.ListAttributes(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return map[string]any{
|
||
|
|
"categories": cats,
|
||
|
|
"attributes": attrs,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SyncStub kept as deprecated alias for EnqueueSync during transition.
|
||
|
|
func (s *Service) SyncStub(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||
|
|
return s.EnqueueSync(ctx, companyID)
|
||
|
|
}
|