package shopify import ( "encoding/json" "strings" "time" ) const ( defaultBatchSize = 25 defaultSyncLimit = 200 maxBatchSize = 100 maxSyncLimit = 1000 maxOrdersSyncLimit = 5000 maxProductIDMap = 5000 maxScheduleIntervalH = 168 // 7 days ) // SyncOptions is stored as JSON on shopify_configs.sync_options. type SyncOptions struct { ProductIDs map[string]int64 `json:"product_ids"` MatchStrategy string `json:"match_strategy"` BatchSize int `json:"batch_size"` SyncLimit int `json:"sync_limit"` PendingSync bool `json:"pending_sync"` PendingOrdersSync bool `json:"pending_orders_sync"` LastSyncStatus string `json:"last_sync_status"` LastSyncError string `json:"last_sync_error"` LastSyncSummary *SyncSummary `json:"last_sync_summary,omitempty"` LastOrdersSyncStatus string `json:"last_orders_sync_status"` LastOrdersSyncError string `json:"last_orders_sync_error"` LastOrdersSyncedAt *time.Time `json:"last_orders_synced_at,omitempty"` OrdersSyncLimit int `json:"orders_sync_limit"` DryRun bool `json:"dry_run"` ScheduleIntervalHours int `json:"schedule_interval_hours"` SchedulePaused bool `json:"schedule_paused"` // One-shot selection for the next product sync (cleared when sync finishes). SyncFilterStatus string `json:"sync_filter_status,omitempty"` SyncFilterCategory string `json:"sync_filter_category,omitempty"` SyncOnlyIDs []string `json:"sync_only_ids,omitempty"` // Auth (Dev Dashboard client_credentials). Legacy permanent tokens leave these empty. AuthMode string `json:"auth_mode,omitempty"` ClientID string `json:"client_id,omitempty"` ClientSecretEnc string `json:"client_secret_enc,omitempty"` TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` } func parseSyncOptions(raw []byte) SyncOptions { opt := SyncOptions{ ProductIDs: map[string]int64{}, MatchStrategy: "sku", BatchSize: defaultBatchSize, SyncLimit: defaultSyncLimit, } if len(raw) == 0 { return opt } _ = json.Unmarshal(raw, &opt) if opt.ProductIDs == nil { opt.ProductIDs = map[string]int64{} } pruneStringInt64Map(opt.ProductIDs, maxProductIDMap) if opt.MatchStrategy == "" { opt.MatchStrategy = "sku" } if opt.BatchSize <= 0 || opt.BatchSize > maxBatchSize { opt.BatchSize = defaultBatchSize } if opt.SyncLimit <= 0 || opt.SyncLimit > maxSyncLimit { opt.SyncLimit = defaultSyncLimit } if opt.OrdersSyncLimit < 0 || opt.OrdersSyncLimit > maxOrdersSyncLimit { opt.OrdersSyncLimit = 0 } if opt.ScheduleIntervalHours < 0 || opt.ScheduleIntervalHours > maxScheduleIntervalH { opt.ScheduleIntervalHours = 0 } opt.SyncOnlyIDs = pruneSyncOnlyIDs(opt.SyncOnlyIDs, maxSyncOnlyIDs) return opt } // resolveScheduleInterval maps schedule_interval_hours to a duration (default when hours<=0). func resolveScheduleInterval(hours int, defaultInterval time.Duration) time.Duration { if defaultInterval <= 0 { defaultInterval = 6 * time.Hour } if hours > 0 { return time.Duration(hours) * time.Hour } return defaultInterval } // isDueForSchedule reports whether last sync is missing or older than interval. func isDueForSchedule(last *time.Time, now time.Time, interval time.Duration) bool { if last == nil { return true } return now.Sub(last.UTC()) >= interval } // shouldEnqueueScheduled reports whether auto product sync should run now. // Paused schedules never enqueue; interval 0 uses defaultInterval (worker default 6h). func shouldEnqueueScheduled(paused bool, hours int, last *time.Time, now time.Time, defaultInterval time.Duration) bool { if paused { return false } return isDueForSchedule(last, now, resolveScheduleInterval(hours, defaultInterval)) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func pruneStringInt64Map(m map[string]int64, max int) { if max <= 0 || len(m) <= max { return } n := len(m) - max for k := range m { delete(m, k) n-- if n <= 0 { return } } }