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) }