Files
descrybe/apps/api/internal/woocommerce/service.go
T

450 lines
15 KiB
Go
Raw Normal View History

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