package postgres import ( "context" "database/sql" "encoding/json" "fmt" "switchboard-core/models" ) type ProviderStore struct{} func NewProviderStore() *ProviderStore { return &ProviderStore{} } const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc, key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at` func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error { return DB.QueryRowContext(ctx, ` INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint, cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault, ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings), cfg.IsActive, cfg.IsPrivate, ).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt) } func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) { row := DB.QueryRowContext(ctx, fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = $1", providerCols), id) return scanProvider(row) } func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error { b := NewUpdate("provider_configs") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.Endpoint != nil { b.Set("endpoint", *patch.Endpoint) } if patch.APIKeyEnc != nil { b.Set("api_key_enc", patch.APIKeyEnc) b.Set("key_nonce", patch.KeyNonce) } if patch.ModelDefault != nil { b.Set("model_default", *patch.ModelDefault) } if patch.Config != nil { b.SetJSON("config", patch.Config) } if patch.Headers != nil { b.SetJSON("headers", patch.Headers) } if patch.Settings != nil { b.SetJSON("settings", patch.Settings) } if patch.IsActive != nil { b.Set("is_active", *patch.IsActive) } if patch.IsPrivate != nil { b.Set("is_private", *patch.IsPrivate) } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *ProviderStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = $1", id) return err } func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) { return s.listByScope(ctx, models.ScopeGlobal, "") } func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { return s.listByScope(ctx, models.ScopeTeam, teamID) } func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) { return s.listByScope(ctx, models.ScopePersonal, userID) } // ListAccessible returns all provider configs a user can access: // global + team (for teams they belong to) + personal. func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf(` SELECT %s FROM provider_configs WHERE is_active = true AND ( scope = 'global' OR (scope = 'personal' AND owner_id = $1) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = $1 )) ) ORDER BY scope, name`, providerCols), userID) if err != nil { return nil, err } defer rows.Close() return scanProviders(rows) } // UserCanAccess checks if a user can use a specific provider config. func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS( SELECT 1 FROM provider_configs WHERE id = $2 AND is_active = true AND ( scope = 'global' OR (scope = 'personal' AND owner_id = $1) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = $1 )) ) )`, userID, configID).Scan(&exists) return exists, err } // ── Internal helpers ──────────────────────── func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) { var rows *sql.Rows var err error if scope == models.ScopeGlobal { rows, err = DB.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND is_active = true ORDER BY name", providerCols), scope) } else { rows, err = DB.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND owner_id = $2 AND is_active = true ORDER BY name", providerCols), scope, ownerID) } if err != nil { return nil, err } defer rows.Close() return scanProviders(rows) } func scanProvider(row *sql.Row) (*models.ProviderConfig, error) { var p models.ProviderConfig var ownerID, modelDefault, keyScope sql.NullString var configJSON, headersJSON, settingsJSON []byte err := row.Scan( &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault, &configJSON, &headersJSON, &settingsJSON, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return nil, err } p.OwnerID = NullableStringPtr(ownerID) p.ModelDefault = modelDefault.String p.KeyScope = keyScope.String json.Unmarshal(configJSON, &p.Config) json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(settingsJSON, &p.Settings) return &p, nil } func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) { var result []models.ProviderConfig for rows.Next() { var p models.ProviderConfig var ownerID, modelDefault, keyScope sql.NullString var configJSON, headersJSON, settingsJSON []byte err := rows.Scan( &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault, &configJSON, &headersJSON, &settingsJSON, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return nil, err } p.OwnerID = NullableStringPtr(ownerID) p.ModelDefault = modelDefault.String p.KeyScope = keyScope.String json.Unmarshal(configJSON, &p.Config) json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(settingsJSON, &p.Settings) result = append(result, p) } return result, rows.Err() } // ── CS4 additions (v0.29.0) ───────────────────────────────────────────── func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) { result, err := DB.ExecContext(ctx, `DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1`, ownerID) if err != nil { return 0, err } return result.RowsAffected() } // ── CS6 additions (v0.29.0) ───────────────────────────────────────────── func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { rows, err := DB.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = $1 ORDER BY name", providerCols), teamID) if err != nil { return nil, err } defer rows.Close() return scanProviders(rows) } func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) { res, err := DB.ExecContext(ctx, `DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, id, teamID) if err != nil { return 0, err } return res.RowsAffected() } // ── CS7a additions (v0.29.0) ──────────────────────────────────────────── func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) { var configID string err := DB.QueryRowContext(ctx, ` SELECT id FROM provider_configs WHERE is_active = true AND ( (scope = 'personal' AND owner_id = $1) OR scope = 'global' ) ORDER BY scope ASC, created_at ASC LIMIT 1 `, userID).Scan(&configID) return configID, err } func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) { row := DB.QueryRowContext(ctx, fmt.Sprintf(` SELECT %s FROM provider_configs WHERE id = $1 AND is_active = true AND (scope = 'global' OR (scope = 'personal' AND owner_id = $2) OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) `, providerCols), configID, userID) return scanProvider(row) }