package sqlite import ( "context" "database/sql" "encoding/json" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type ExtensionStore struct{} func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} } func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error { ext.ID = store.NewID() now := time.Now().UTC() ext.CreatedAt = now ext.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO extensions (id, ext_id, name, version, tier, description, author, manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author, ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope, models.NullString(ext.TeamID), models.NullString(ext.InstalledBy), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) { return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id) } func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) { return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID) } func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error { _, err := DB.ExecContext(ctx, ` UPDATE extensions SET name = ?, version = ?, description = ?, author = ?, manifest = ?, is_system = ?, is_enabled = ?, updated_at = datetime('now') WHERE id = ?`, ext.Name, ext.Version, ext.Description, ext.Author, ext.Manifest, ext.IsSystem, ext.IsEnabled, id, ) return err } func (s *ExtensionStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id) return err } func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) { return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`) } func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) { return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`) } // ListForUser returns all enabled extensions with user-specific overrides merged in. // System extensions are always included (users can't disable them). // Non-system extensions respect per-user enabled toggle. func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) { rows, err := DB.QueryContext(ctx, ` SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author, e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by, e.created_at, e.updated_at, eus.is_enabled AS user_enabled, eus.settings AS user_settings FROM extensions e LEFT JOIN extension_user_settings eus ON eus.extension_id = e.id AND eus.user_id = ? WHERE e.is_enabled = 1 AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1) ORDER BY e.name`, userID) if err != nil { return nil, err } defer rows.Close() var result []models.UserExtension for rows.Next() { var ue models.UserExtension var teamID, installedBy sql.NullString var userEnabled sql.NullBool var userSettings []byte if err := rows.Scan( &ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier, &ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem, &ue.IsEnabled, &ue.Scope, &teamID, &installedBy, st(&ue.CreatedAt), st(&ue.UpdatedAt), &userEnabled, &userSettings, ); err != nil { return nil, err } ue.TeamID = NullableStringPtr(teamID) ue.InstalledBy = NullableStringPtr(installedBy) if userEnabled.Valid { ue.UserEnabled = &userEnabled.Bool } if userSettings != nil { raw := json.RawMessage(userSettings) ue.UserSettings = &raw } result = append(result, ue) } if result == nil { result = make([]models.UserExtension, 0) } return result, rows.Err() } func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) { var eus models.ExtensionUserSettings err := DB.QueryRowContext(ctx, ` SELECT extension_id, user_id, settings, is_enabled FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`, extID, userID, ).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled) if err != nil { return nil, err } return &eus, nil } func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error { _, err := DB.ExecContext(ctx, ` INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled) VALUES (?, ?, ?, ?) ON CONFLICT (extension_id, user_id) DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`, eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled, ) return err } func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error { _, err := DB.ExecContext(ctx, ` DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`, extID, userID, ) return err } // ── Internal helpers ──────────────────────────── func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) { var ext models.Extension var teamID, installedBy sql.NullString err := DB.QueryRowContext(ctx, query, args...).Scan( &ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier, &ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem, &ext.IsEnabled, &ext.Scope, &teamID, &installedBy, st(&ext.CreatedAt), st(&ext.UpdatedAt), ) if err != nil { return nil, err } ext.TeamID = NullableStringPtr(teamID) ext.InstalledBy = NullableStringPtr(installedBy) return &ext, nil } func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) { rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var result []models.Extension for rows.Next() { var ext models.Extension var teamID, installedBy sql.NullString if err := rows.Scan( &ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier, &ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem, &ext.IsEnabled, &ext.Scope, &teamID, &installedBy, st(&ext.CreatedAt), st(&ext.UpdatedAt), ); err != nil { return nil, err } ext.TeamID = NullableStringPtr(teamID) ext.InstalledBy = NullableStringPtr(installedBy) result = append(result, ext) } if result == nil { result = make([]models.Extension, 0) } return result, rows.Err() }