package store import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" ) // IntegrationPolicyKey is the operator's global on/off switch for external services that // have no configuration document of their own. // // It is deliberately *not* a second copy of every integration's switch. Sonarr and Radarr // already store theirs in the arr integration policy and MDBList stores its own in the // ratings settings; moving those here would mean a migration and, worse, a window in which // two documents disagreed about whether a service was on. The rule is one stored truth per // integration, kept where that integration's other configuration already lives — and this // document is that home for the ones that have nowhere else, which today is Tracearr. // // The API's integrationEnabled/setIntegrationEnabled pair is the single reader and writer, // so the console never has to know which document answers for which service. const IntegrationPolicyKey = "integration_policy" // IntegrationPolicy is a map of integration id to whether it is switched on. // // Absence means on. A household that upgrades into this feature has every service it had // configured still working, which is the only safe reading: the alternative silently turns // off recommendation imports on the day the console gained a switch for them. type IntegrationPolicy struct { Disabled map[string]bool `json:"disabled"` UpdatedAt time.Time `json:"updatedAt"` } // Enabled reports whether one integration is switched on. A service nobody has ever // touched has no entry, and no entry is on. func (p IntegrationPolicy) Enabled(id string) bool { return !p.Disabled[strings.TrimSpace(id)] } func (s *Store) IntegrationPolicy(ctx context.Context) (IntegrationPolicy, error) { empty := IntegrationPolicy{Disabled: map[string]bool{}} var raw []byte err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, IntegrationPolicyKey).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return empty, nil } if err != nil { return empty, fmt.Errorf("store: read integration policy: %w", err) } var policy IntegrationPolicy if err := json.Unmarshal(raw, &policy); err != nil { return empty, fmt.Errorf("store: decode integration policy: %w", err) } if policy.Disabled == nil { policy.Disabled = map[string]bool{} } return policy, nil } // SetIntegrationEnabled records one service's switch, leaving every other entry alone. // // Read-modify-write rather than a whole-document put, because the console sends one // switch at a time and a put would let a page rendered before another integration existed // silently re-enable it. func (s *Store) SetIntegrationEnabled(ctx context.Context, id string, enabled bool) error { id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("store: integration policy needs an id") } policy, err := s.IntegrationPolicy(ctx) if err != nil { return err } if enabled { delete(policy.Disabled, id) } else { policy.Disabled[id] = true } policy.UpdatedAt = time.Now().UTC() raw, err := json.Marshal(policy) if err != nil { return err } _, err = s.pool.Exec(ctx, ` INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, IntegrationPolicyKey, string(raw)) if err != nil { return fmt.Errorf("store: write integration policy: %w", err) } return nil }