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,449 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user