package shopify 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("shopify not configured") ErrNotEnabled = errors.New("shopify sync is disabled") ErrMissingCreds = errors.New("shopify 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 { ShopDomain string `json:"shop_domain"` APIVersion string `json:"api_version"` 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"` HasClientCredentials bool `json:"has_client_credentials"` AuthMode string `json:"auth_mode,omitempty"` PendingSync bool `json:"pending_sync"` PendingOrdersSync bool `json:"pending_orders_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"` DryRun bool `json:"dry_run"` ReviewsSupported bool `json:"reviews_supported"` ScheduleIntervalHours int `json:"schedule_interval_hours"` SchedulePaused bool `json:"schedule_paused"` } // UpdateInput is the PUT /api/shopify body (secrets never echoed back). type UpdateInput struct { ShopDomain string AccessToken string APIVersion string ClientID string ClientSecret string IsEnabled bool DryRun bool } func NewService(pool *pgxpool.Pool, key []byte) *Service { return &Service{ Pool: pool, Key: key, // Dial-time SSRF; no loopback (Admin API is always public *.myshopify.com). HTTPClient: security.SafeHTTPClient(defaultTimeout, false), } } type storedConfig struct { shopDomain, tokenEnc, apiVersion 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 shop_domain, access_token, api_version, is_enabled, sync_options, last_synced_at, last_test_at, last_test_status FROM shopify_configs WHERE company_id = $1`, companyID).Scan( &sc.shopDomain, &sc.tokenEnc, &sc.apiVersion, &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) token, _ := DecryptSecret(s.Key, sc.tokenEnc) apiVersion := normalizeAPIVersion(sc.apiVersion) hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != "" authMode := strings.TrimSpace(opt.AuthMode) if authMode == "" && hasClientCreds { authMode = authModeClientCredentials } if authMode == "" && token != "" { authMode = authModeLegacyToken } return Config{ ShopDomain: sc.shopDomain, APIVersion: apiVersion, IsEnabled: sc.enabled, Configured: true, LastSyncedAt: sc.lastSync, LastTestAt: sc.lastTest, LastTestStatus: sc.status, HasCredentials: token != "" || hasClientCreds, HasClientCredentials: hasClientCreds, AuthMode: authMode, PendingSync: opt.PendingSync, PendingOrdersSync: opt.PendingOrdersSync, MatchStrategy: opt.MatchStrategy, LastSyncStatus: opt.LastSyncStatus, LastSyncError: opt.LastSyncError, LastSyncSummary: opt.LastSyncSummary, ProductMapCount: len(opt.ProductIDs), LastOrdersSyncedAt: opt.LastOrdersSyncedAt, LastOrdersSyncStatus: opt.LastOrdersSyncStatus, LastOrdersSyncError: opt.LastOrdersSyncError, DryRun: opt.DryRun || strings.EqualFold(token, dryRunToken), ReviewsSupported: false, ScheduleIntervalHours: opt.ScheduleIntervalHours, SchedulePaused: opt.SchedulePaused, }, nil } func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (Config, error) { normalized, err := NormalizeShopDomain(in.ShopDomain) if err != nil { return Config{}, err } apiVersion := normalizeAPIVersion(in.APIVersion) clientID := strings.TrimSpace(in.ClientID) clientSecret := strings.TrimSpace(in.ClientSecret) accessToken := strings.TrimSpace(in.AccessToken) if (clientID == "") != (clientSecret == "") { return Config{}, ErrInvalidClientCredentials } sc, loadErr := s.loadStored(ctx, companyID) opt := SyncOptions{} if loadErr == nil { opt = parseSyncOptions(sc.syncOptions) } else if !errors.Is(loadErr, pgx.ErrNoRows) { return Config{}, loadErr } opt.DryRun = in.DryRun tokenEnc := "" switch { case clientID != "" && clientSecret != "": secretEnc, encErr := EncryptSecret(s.Key, clientSecret) if encErr != nil { return Config{}, encErr } opt.AuthMode = authModeClientCredentials opt.ClientID = clientID opt.ClientSecretEnc = secretEnc if in.DryRun || strings.EqualFold(accessToken, dryRunToken) { tokenEnc, err = EncryptSecret(s.Key, dryRunToken) if err != nil { return Config{}, err } opt.TokenExpiresAt = nil } else { tok, exErr := ExchangeClientCredentials(ctx, s.HTTPClient, normalized, clientID, clientSecret) if exErr != nil { return Config{}, exErr } tokenEnc, err = EncryptSecret(s.Key, tok.AccessToken) if err != nil { return Config{}, err } exp := tok.ExpiresAt opt.TokenExpiresAt = &exp } case accessToken != "": tokenEnc, err = EncryptSecret(s.Key, accessToken) if err != nil { return Config{}, err } opt.AuthMode = authModeLegacyToken opt.ClientID = "" opt.ClientSecretEnc = "" opt.TokenExpiresAt = nil } rawOpt, err := json.Marshal(opt) if err != nil { return Config{}, err } _, err = s.Pool.Exec(ctx, ` INSERT INTO shopify_configs (company_id, shop_domain, access_token, api_version, is_enabled, sync_options, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (company_id) DO UPDATE SET shop_domain = EXCLUDED.shop_domain, access_token = CASE WHEN EXCLUDED.access_token <> '' THEN EXCLUDED.access_token ELSE shopify_configs.access_token END, api_version = EXCLUDED.api_version, is_enabled = EXCLUDED.is_enabled, sync_options = EXCLUDED.sync_options, updated_at = now()`, companyID, normalized, tokenEnc, apiVersion, in.IsEnabled, rawOpt) 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 shopify_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 } opt := parseSyncOptions(sc.syncOptions) token, err := DecryptSecret(s.Key, sc.tokenEnc) if err != nil { return nil, sc, SyncOptions{}, err } if sc.shopDomain == "" { return nil, sc, SyncOptions{}, ErrMissingCreds } hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != "" if token == "" && !hasClientCreds { return nil, sc, SyncOptions{}, ErrMissingCreds } if !opt.DryRun && hasClientCreds && (token == "" || strings.EqualFold(token, dryRunToken) || tokenNeedsRefresh(opt.TokenExpiresAt, time.Now().UTC())) { secret, decErr := DecryptSecret(s.Key, opt.ClientSecretEnc) if decErr != nil { return nil, sc, SyncOptions{}, decErr } tok, exErr := ExchangeClientCredentials(ctx, s.HTTPClient, sc.shopDomain, opt.ClientID, secret) if exErr != nil { return nil, sc, SyncOptions{}, exErr } tokenEnc, encErr := EncryptSecret(s.Key, tok.AccessToken) if encErr != nil { return nil, sc, SyncOptions{}, encErr } exp := tok.ExpiresAt opt.AuthMode = authModeClientCredentials opt.TokenExpiresAt = &exp rawOpt, marshalErr := json.Marshal(opt) if marshalErr != nil { return nil, sc, SyncOptions{}, marshalErr } if _, execErr := s.Pool.Exec(ctx, ` UPDATE shopify_configs SET access_token = $2, sync_options = $3, updated_at = now() WHERE company_id = $1`, companyID, tokenEnc, rawOpt); execErr != nil { return nil, sc, SyncOptions{}, execErr } sc.tokenEnc = tokenEnc sc.syncOptions = rawOpt token = tok.AccessToken } if token == "" { return nil, sc, SyncOptions{}, ErrMissingCreds } client := NewClient(sc.shopDomain, token, sc.apiVersion, s.HTTPClient) if opt.DryRun { client.DryRun = true } return client, 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 shopify_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 } shop, err := client.TestConnection(ctx) if err != nil { status = "failed" message = "connection failed" _, execErr := s.Pool.Exec(ctx, ` UPDATE shopify_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, "dry_run": client.DryRun}, err } if _, err := s.Pool.Exec(ctx, ` UPDATE shopify_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 } out := map[string]any{"status": status, "message": message, "dry_run": client.DryRun} if shop != nil { out["shop_name"] = shop.Name out["shop_domain"] = shop.Domain out["currency"] = shop.Currency } return out, nil } func (s *Service) EnqueueSync(ctx context.Context, companyID uuid.UUID, scopes ...ProductSyncScope) (map[string]any, error) { if err := s.requireEnabledCreds(ctx, companyID); err != nil { return nil, err } sc, err := s.loadStored(ctx, companyID) if err != nil { return nil, err } 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 if err := s.saveSyncOptions(ctx, companyID, opt); err != nil { return nil, err } return map[string]any{"status": "accepted", "message": "shopify product sync queued"}, nil } func (s *Service) EnqueueOrdersSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) { if err := s.requireEnabledCreds(ctx, companyID); err != nil { return nil, err } sc, err := s.loadStored(ctx, companyID) if err != nil { return nil, err } opt := parseSyncOptions(sc.syncOptions) opt.PendingOrdersSync = true if err := s.saveSyncOptions(ctx, companyID, opt); err != nil { return nil, err } return map[string]any{"status": "accepted", "message": "shopify orders sync queued"}, nil } func (s *Service) requireEnabledCreds(ctx context.Context, companyID uuid.UUID) error { sc, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { return ErrNotConfigured } if err != nil { return err } if !sc.enabled { return ErrNotEnabled } token, err := DecryptSecret(s.Key, sc.tokenEnc) if err != nil { return err } opt := parseSyncOptions(sc.syncOptions) hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != "" if sc.shopDomain == "" || (token == "" && !hasClientCreds) { return ErrMissingCreds } return nil } func (s *Service) saveSyncOptions(ctx context.Context, companyID uuid.UUID, opt SyncOptions) error { pruneStringInt64Map(opt.ProductIDs, maxProductIDMap) raw, err := json.Marshal(opt) if err != nil { return err } _, err = s.Pool.Exec(ctx, ` UPDATE shopify_configs SET sync_options = $2, updated_at = now() WHERE company_id = $1`, companyID, raw) return err } // ClaimNextPendingJob claims the next pending Shopify sync (products or orders). func (s *Service) ClaimNextPendingJob(ctx context.Context) (uuid.UUID, string, error) { var companyID uuid.UUID var kind string err := s.Pool.QueryRow(ctx, ` WITH candidate AS ( SELECT company_id, CASE WHEN COALESCE(sync_options->>'pending_sync', 'false') = 'true' THEN 'products' WHEN COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' THEN 'orders' ELSE '' END AS kind FROM shopify_configs WHERE is_enabled = true AND ( COALESCE(sync_options->>'pending_sync', 'false') = 'true' OR COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' ) ORDER BY updated_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED ) UPDATE shopify_configs c SET sync_options = CASE candidate.kind WHEN 'products' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_sync}', 'false'::jsonb, true) WHEN 'orders' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_orders_sync}', 'false'::jsonb, true) ELSE c.sync_options END, updated_at = now() FROM candidate WHERE c.company_id = candidate.company_id AND candidate.kind <> '' RETURNING c.company_id, candidate.kind`).Scan(&companyID, &kind) return companyID, kind, err } // EnqueueDueScheduled marks enabled Shopify configs pending when last_synced_at is stale. 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 shopify_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() }