backend: move user settings to relational table

This commit is contained in:
rishikanthc
2026-05-03 13:27:56 -07:00
parent 05cd66153c
commit 70f03d4cd6
9 changed files with 276 additions and 42 deletions

View File

@@ -27,10 +27,12 @@ var (
ErrSmallLLMRequired = errors.New("small LLM model is required")
ErrAPIKeyNotFound = errors.New("api key not found")
ErrUserDisabled = errors.New("user is disabled")
ErrSettingsUnavailable = errors.New("user settings repository is unavailable")
)
type Service struct {
users repository.UserRepository
settings repository.UserSettingsRepository
refreshTokens repository.RefreshTokenRepository
apiKeys repository.APIKeyRepository
profiles repository.ProfileRepository
@@ -52,9 +54,10 @@ type SettingsUpdate struct {
AutoRenameEnabled *bool
}
func NewService(users repository.UserRepository, refreshTokens repository.RefreshTokenRepository, apiKeys repository.APIKeyRepository, profiles repository.ProfileRepository, llmConfigs repository.LLMConfigRepository, authService *auth.AuthService) *Service {
func NewService(users repository.UserRepository, settings repository.UserSettingsRepository, refreshTokens repository.RefreshTokenRepository, apiKeys repository.APIKeyRepository, profiles repository.ProfileRepository, llmConfigs repository.LLMConfigRepository, authService *auth.AuthService) *Service {
return &Service{
users: users,
settings: settings,
refreshTokens: refreshTokens,
apiKeys: apiKeys,
profiles: profiles,
@@ -185,6 +188,10 @@ func (s *Service) UpdateSettings(ctx context.Context, userID uint, update Settin
if err != nil {
return nil, err
}
settings, err := s.settingsForUser(ctx, user)
if err != nil {
return nil, err
}
defaultProfileID := user.DefaultProfileID
if update.DefaultProfileIDSet {
defaultProfileID = update.DefaultProfileID
@@ -211,10 +218,13 @@ func (s *Service) UpdateSettings(ctx context.Context, userID uint, update Settin
user.DefaultProfileID = defaultProfileID
user.AutoTranscriptionEnabled = autoTranscription
user.AutoRenameEnabled = autoRename
if err := s.users.Update(ctx, user); err != nil {
settings.DefaultProfileID = defaultProfileID
settings.AutoTranscriptionEnabled = autoTranscription
settings.AutoRenameEnabled = autoRename
if err := s.settings.Upsert(ctx, settings); err != nil {
return nil, err
}
return user, nil
return s.users.FindByID(ctx, userID)
}
func (s *Service) ListAPIKeys(ctx context.Context, userID uint) ([]models.APIKey, error) {
@@ -310,6 +320,31 @@ func (s *Service) smallLLMReady(ctx context.Context, userID uint) bool {
strings.TrimSpace(*config.SmallModel) != ""
}
func (s *Service) settingsForUser(ctx context.Context, user *models.User) (*models.UserSettings, error) {
if s.settings == nil {
return nil, ErrSettingsUnavailable
}
settings, err := s.settings.FindByUser(ctx, user.ID)
if errors.Is(err, gorm.ErrRecordNotFound) {
settings = &models.UserSettings{
UserID: user.ID,
DefaultProfileID: user.DefaultProfileID,
AutoTranscriptionEnabled: user.AutoTranscriptionEnabled,
AutoRenameEnabled: user.AutoRenameEnabled,
SummaryDefaultModel: user.SummaryDefaultModel,
}
if user.SettingsJSON == "" {
settings.AutoTranscriptionEnabled = true
settings.AutoRenameEnabled = true
}
return settings, nil
}
if err != nil {
return nil, err
}
return settings, nil
}
func llmBaseURL(config *models.LLMConfig) string {
if config.BaseURL != nil && strings.TrimSpace(*config.BaseURL) != "" {
return strings.TrimSpace(*config.BaseURL)

View File

@@ -72,6 +72,7 @@ func newAuthTestServer(t *testing.T) *authTestServer {
require.NoError(t, err)
accountService := account.NewService(
repository.NewUserRepository(database.DB),
repository.NewUserSettingsRepository(database.DB),
repository.NewRefreshTokenRepository(database.DB),
repository.NewAPIKeyRepository(database.DB),
profileRepo,

View File

@@ -237,10 +237,13 @@ func TestSettingsPartialUpdateAndValidation(t *testing.T) {
require.Equal(t, profileID, body["default_profile_id"])
require.Equal(t, true, body["local_only"])
var settings models.UserSettings
require.NoError(t, database.DB.First(&settings).Error)
require.NotNil(t, settings.DefaultProfileID)
require.Equal(t, strings.TrimPrefix(profileID, "profile_"), *settings.DefaultProfileID)
var user models.User
require.NoError(t, database.DB.First(&user).Error)
require.NotNil(t, user.DefaultProfileID)
require.Equal(t, strings.TrimPrefix(profileID, "profile_"), *user.DefaultProfileID)
require.NotContains(t, user.SettingsJSON, strings.TrimPrefix(profileID, "profile_"))
resp, body = s.request(t, http.MethodPatch, "/api/v1/settings", map[string]any{
"default_profile_id": "profile_missing",

View File

@@ -74,6 +74,7 @@ func Build(cfg *config.Config) (*App, error) {
profileRepo := repository.NewProfileRepository(database.DB)
tagRepo := repository.NewTagRepository(database.DB)
userRepo := repository.NewUserRepository(database.DB)
userSettingsRepo := repository.NewUserSettingsRepository(database.DB)
refreshTokenRepo := repository.NewRefreshTokenRepository(database.DB)
apiKeyRepo := repository.NewAPIKeyRepository(database.DB)
chatRepo := repository.NewChatRepository(database.DB)
@@ -104,7 +105,7 @@ func Build(cfg *config.Config) (*App, error) {
})
summaryService := summarization.NewService(summaryRepo, llmConfigRepo, jobRepo, summarization.Config{})
chatService := chatdomain.NewService(chatRepo, llmConfigRepo)
accountService := account.NewService(userRepo, refreshTokenRepo, apiKeyRepo, profileRepo, llmConfigRepo, authService)
accountService := account.NewService(userRepo, userSettingsRepo, refreshTokenRepo, apiKeyRepo, profileRepo, llmConfigRepo, authService)
adminService := admin.NewService(userRepo, refreshTokenRepo, apiKeyRepo)
profileService := profiledomain.NewService(profileRepo)
llmProviderService := llmprovider.NewService(llmConfigRepo, llmprovider.HTTPConnectionTester{})

View File

@@ -63,6 +63,7 @@ func TestFreshSchemaInitialization(t *testing.T) {
expectedTables := []string{
"schema_migrations",
"users",
"user_settings",
"api_keys",
"refresh_tokens",
"transcription_profiles",
@@ -121,11 +122,18 @@ func TestFreshSchemaInitialization(t *testing.T) {
assert.True(t, db.Migrator().HasColumn(&models.User{}, "status"))
assert.True(t, db.Migrator().HasColumn(&models.User{}, "last_login_at"))
assert.True(t, db.Migrator().HasColumn(&models.User{}, "password_changed_at"))
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "default_profile_id"))
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "auto_transcription_enabled"))
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "auto_rename_enabled"))
title := "Fresh transcription"
user := models.User{Username: "fresh-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, repository.NewUserRepository(db).Create(t.Context(), &user))
assert.Equal(t, models.UserStatusActive, user.Status)
var userSettings models.UserSettings
require.NoError(t, db.First(&userSettings, "user_id = ?", user.ID).Error)
assert.True(t, userSettings.AutoTranscriptionEnabled)
assert.True(t, userSettings.AutoRenameEnabled)
job := models.TranscriptionJob{UserID: 1, Title: &title, Status: models.StatusUploaded, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)

View File

@@ -11,10 +11,11 @@ import (
"gorm.io/gorm"
)
const latestSchemaVersion = 9
const latestSchemaVersion = 10
var schemaModels = []any{
&models.User{},
&models.UserSettings{},
&models.APIKey{},
&models.RefreshToken{},
&models.TranscriptionProfile{},

View File

@@ -12,14 +12,15 @@ import (
type migrationStep func(*gorm.DB) error
var schemaSteps = map[int]migrationStep{
2: migrateStepV1ToV2,
3: migrateStepV2ToV3,
4: migrateStepV3ToV4,
5: migrateStepV4ToV5,
6: migrateStepV5ToV6,
7: migrateStepV6ToV7,
8: migrateStepV7ToV8,
9: migrateStepV8ToV9,
2: migrateStepV1ToV2,
3: migrateStepV2ToV3,
4: migrateStepV3ToV4,
5: migrateStepV4ToV5,
6: migrateStepV5ToV6,
7: migrateStepV6ToV7,
8: migrateStepV7ToV8,
9: migrateStepV8ToV9,
10: migrateStepV9ToV10,
}
func runSchemaSteps(tx *gorm.DB, currentVersion int) error {
@@ -78,6 +79,13 @@ func migrateStepV8ToV9(tx *gorm.DB) error {
return tx.Exec(`CREATE INDEX IF NOT EXISTS idx_recording_sessions_artifact_cleanup ON recording_sessions(status, temporary_artifacts_cleaned_at)`).Error
}
func migrateStepV9ToV10(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.UserSettings{}); err != nil {
return err
}
return backfillUserSettings(tx)
}
func backfillCompatibilityColumns(tx *gorm.DB) error {
if err := backfillUsers(tx); err != nil {
return err
@@ -120,6 +128,38 @@ func backfillUsers(tx *gorm.DB) error {
return err
}
}
if err := backfillUserSettings(tx); err != nil {
return err
}
return nil
}
func backfillUserSettings(tx *gorm.DB) error {
var users []models.User
if err := tx.Find(&users).Error; err != nil {
return err
}
for _, user := range users {
settings := models.UserSettings{
UserID: user.ID,
DefaultProfileID: user.DefaultProfileID,
AutoTranscriptionEnabled: user.AutoTranscriptionEnabled,
AutoRenameEnabled: user.AutoRenameEnabled,
SummaryDefaultModel: user.SummaryDefaultModel,
}
if settings.UserID == 0 {
continue
}
if !settings.AutoTranscriptionEnabled && user.SettingsJSON == "" {
settings.AutoTranscriptionEnabled = true
}
if !settings.AutoRenameEnabled && user.SettingsJSON == "" {
settings.AutoRenameEnabled = true
}
if err := tx.Where("user_id = ?", settings.UserID).Assign(settings).FirstOrCreate(&settings).Error; err != nil {
return err
}
}
return nil
}

View File

@@ -18,6 +18,25 @@ type userSettings struct {
SummaryDefaultModel string `json:"summary_default_model,omitempty"`
}
type UserSettings struct {
UserID uint `json:"user_id" gorm:"primaryKey;not null"`
DefaultProfileID *string `json:"default_profile_id,omitempty" gorm:"type:varchar(36);index"`
AutoTranscriptionEnabled bool `json:"auto_transcription_enabled" gorm:"not null;default:true"`
AutoRenameEnabled bool `json:"auto_rename_enabled" gorm:"not null;default:true"`
SummaryDefaultModel string `json:"summary_default_model,omitempty" gorm:"type:varchar(100)"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
User User `json:"-" gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE"`
}
func (UserSettings) TableName() string { return "user_settings" }
func (s *UserSettings) ApplyDefaults() {
s.AutoTranscriptionEnabled = true
s.AutoRenameEnabled = true
}
// RefreshToken represents a persistent refresh token for rotating access.
type RefreshToken struct {
ID uint `json:"id" gorm:"primaryKey"`

View File

@@ -11,6 +11,7 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrQueueClaimConflict = errors.New("queue claim is no longer owned by this worker")
@@ -37,12 +38,52 @@ func NewUserRepository(db *gorm.DB) UserRepository {
}
}
func (r *userRepository) Create(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(user).Error; err != nil {
return err
}
settings := models.UserSettings{
UserID: user.ID,
DefaultProfileID: user.DefaultProfileID,
AutoTranscriptionEnabled: true,
AutoRenameEnabled: true,
SummaryDefaultModel: user.SummaryDefaultModel,
}
if user.AutoTranscriptionEnabled {
settings.AutoTranscriptionEnabled = user.AutoTranscriptionEnabled
}
if user.AutoRenameEnabled {
settings.AutoRenameEnabled = user.AutoRenameEnabled
}
return tx.FirstOrCreate(&settings, "user_id = ?", user.ID).Error
})
}
func (r *userRepository) FindByID(ctx context.Context, id interface{}) (*models.User, error) {
var user models.User
if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil {
return nil, err
}
if err := r.hydrateSettings(ctx, &user); err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) Update(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*models.User, error) {
var user models.User
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
if err := r.hydrateSettings(ctx, &user); err != nil {
return nil, err
}
return &user, nil
}
@@ -67,6 +108,11 @@ func (r *userRepository) ListUsersForAdmin(ctx context.Context, offset, limit in
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil {
return nil, 0, err
}
for i := range users {
if err := r.hydrateSettings(ctx, &users[i]); err != nil {
return nil, 0, err
}
}
return users, count, nil
}
@@ -85,17 +131,80 @@ func (r *userRepository) CountActiveAdmins(ctx context.Context) (int64, error) {
}
func (r *userRepository) CountWithAutoTranscription(ctx context.Context) (int64, error) {
var users []models.User
if err := r.db.WithContext(ctx).Find(&users).Error; err != nil {
return 0, err
}
var count int64
for _, user := range users {
if user.AutoTranscriptionEnabled {
count++
}
err := r.db.WithContext(ctx).Model(&models.UserSettings{}).
Where("auto_transcription_enabled = ?", true).
Count(&count).Error
return count, err
}
func (r *userRepository) hydrateSettings(ctx context.Context, user *models.User) error {
if user == nil || user.ID == 0 {
return nil
}
return count, nil
var settings models.UserSettings
err := r.db.WithContext(ctx).First(&settings, "user_id = ?", user.ID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
user.DefaultProfileID = settings.DefaultProfileID
user.AutoTranscriptionEnabled = settings.AutoTranscriptionEnabled
user.AutoRenameEnabled = settings.AutoRenameEnabled
user.SummaryDefaultModel = settings.SummaryDefaultModel
return nil
}
type UserSettingsRepository interface {
FindByUser(ctx context.Context, userID uint) (*models.UserSettings, error)
Upsert(ctx context.Context, settings *models.UserSettings) error
}
type userSettingsRepository struct {
db *gorm.DB
}
func NewUserSettingsRepository(db *gorm.DB) UserSettingsRepository {
return &userSettingsRepository{db: db}
}
func (r *userSettingsRepository) FindByUser(ctx context.Context, userID uint) (*models.UserSettings, error) {
var settings models.UserSettings
if err := r.db.WithContext(ctx).First(&settings, "user_id = ?", userID).Error; err != nil {
return nil, err
}
return &settings, nil
}
func (r *userSettingsRepository) Upsert(ctx context.Context, settings *models.UserSettings) error {
if settings == nil {
return fmt.Errorf("user settings are required")
}
if settings.UserID == 0 {
return fmt.Errorf("user settings user ID is required")
}
now := time.Now()
values := map[string]any{
"user_id": settings.UserID,
"default_profile_id": settings.DefaultProfileID,
"auto_transcription_enabled": settings.AutoTranscriptionEnabled,
"auto_rename_enabled": settings.AutoRenameEnabled,
"summary_default_model": settings.SummaryDefaultModel,
"created_at": now,
"updated_at": now,
}
return r.db.WithContext(ctx).Table((&models.UserSettings{}).TableName()).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"default_profile_id",
"auto_transcription_enabled",
"auto_rename_enabled",
"summary_default_model",
"updated_at",
}),
}).Create(values).Error
}
type FileListOptions struct {
@@ -1698,24 +1807,38 @@ func (r *profileRepository) SetDefaultForUser(ctx context.Context, id string, us
}
func saveUserDefaultProfileTx(tx *gorm.DB, userID uint, profileID *string) error {
var user models.User
if err := tx.First(&user, userID).Error; err != nil {
return err
settings := models.UserSettings{
UserID: userID,
DefaultProfileID: profileID,
AutoTranscriptionEnabled: true,
AutoRenameEnabled: true,
}
user.DefaultProfileID = profileID
return tx.Save(&user).Error
return tx.Where("user_id = ?", userID).Assign(map[string]any{
"default_profile_id": profileID,
}).FirstOrCreate(&settings).Error
}
func clearUserDefaultProfileIfMatchesTx(tx *gorm.DB, userID uint, profileID string) error {
result := tx.Model(&models.UserSettings{}).
Where("user_id = ? AND default_profile_id = ?", userID, profileID).
Update("default_profile_id", nil)
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return nil
}
var user models.User
if err := tx.First(&user, userID).Error; err != nil {
return err
}
if user.DefaultProfileID == nil || *user.DefaultProfileID != profileID {
return nil
settings := models.UserSettings{
UserID: userID,
DefaultProfileID: nil,
AutoTranscriptionEnabled: true,
AutoRenameEnabled: true,
}
user.DefaultProfileID = nil
return tx.Save(&user).Error
return tx.Where("user_id = ?", userID).FirstOrCreate(&settings).Error
}
// LLMConfigRepository handles LLM configuration operations
@@ -1849,20 +1972,23 @@ func (r *summaryRepository) FindByIDForUser(ctx context.Context, id string, user
}
func (r *summaryRepository) GetSettingsByUser(ctx context.Context, userID uint) (*models.SummarySetting, error) {
var user models.User
if err := r.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
var settings models.UserSettings
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&settings).Error; err != nil {
return nil, err
}
return &models.SummarySetting{DefaultModel: user.SummaryDefaultModel}, nil
return &models.SummarySetting{DefaultModel: settings.SummaryDefaultModel}, nil
}
func (r *summaryRepository) SaveSettingsByUser(ctx context.Context, userID uint, settings *models.SummarySetting) error {
var user models.User
if err := r.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
return err
userSettings := models.UserSettings{
UserID: userID,
AutoTranscriptionEnabled: true,
AutoRenameEnabled: true,
SummaryDefaultModel: settings.DefaultModel,
}
user.SummaryDefaultModel = settings.DefaultModel
return r.db.WithContext(ctx).Save(&user).Error
return r.db.WithContext(ctx).Where("user_id = ?", userID).Assign(map[string]any{
"summary_default_model": settings.DefaultModel,
}).FirstOrCreate(&userSettings).Error
}
func (r *summaryRepository) SaveSummary(ctx context.Context, summary *models.Summary) error {