diff --git a/models/fixtures/user_notification_settings.yml b/models/fixtures/user_notification_settings.yml new file mode 100644 index 0000000000..57df18f513 --- /dev/null +++ b/models/fixtures/user_notification_settings.yml @@ -0,0 +1,84 @@ +- user_id: 1 + actions: failureonly +- user_id: 2 + actions: failureonly +- user_id: 3 + actions: failureonly +- user_id: 4 + actions: failureonly +- user_id: 5 + actions: failureonly +- user_id: 6 + actions: failureonly +- user_id: 7 + actions: failureonly +- user_id: 8 + actions: failureonly +- user_id: 9 + actions: failureonly +- user_id: 10 + actions: failureonly +- user_id: 11 + actions: failureonly +- user_id: 12 + actions: failureonly +- user_id: 13 + actions: failureonly +- user_id: 14 + actions: failureonly +- user_id: 15 + actions: failureonly +- user_id: 16 + actions: failureonly +- user_id: 17 + actions: failureonly +- user_id: 18 + actions: failureonly +- user_id: 19 + actions: failureonly +- user_id: 20 + actions: failureonly +- user_id: 21 + actions: failureonly +- user_id: 22 + actions: failureonly +- user_id: 23 + actions: failureonly +- user_id: 24 + actions: failureonly +- user_id: 25 + actions: failureonly +- user_id: 26 + actions: failureonly +- user_id: 27 + actions: failureonly +- user_id: 28 + actions: failureonly +- user_id: 29 + actions: failureonly +- user_id: 30 + actions: failureonly +- user_id: 31 + actions: failureonly +- user_id: 32 + actions: failureonly +- user_id: 33 + actions: failureonly +- user_id: 34 + actions: failureonly +- user_id: 35 + actions: failureonly +- user_id: 36 + actions: failureonly +- user_id: 37 + actions: failureonly +- user_id: 38 + actions: failureonly +- user_id: 39 + actions: failureonly +- user_id: 40 + actions: failureonly +- user_id: 41 + actions: failureonly +- user_id: 42 + actions: failureonly diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 176372486e..46659bf247 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -382,6 +382,7 @@ func prepareMigrationTasks() []*migration { newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), + newMigration(321, "Add new table for fine-grained notification settings", v1_24.AddFineGrainedActionsNotificationSettings), } return preparedMigrations } diff --git a/models/migrations/v1_24/v321.go b/models/migrations/v1_24/v321.go new file mode 100644 index 0000000000..3e26f0f961 --- /dev/null +++ b/models/migrations/v1_24/v321.go @@ -0,0 +1,59 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_24 + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/xorm" +) + +type NotificationSettings struct { + UserID int64 `xorm:"pk"` + Actions string `xorm:"NOT NULL DEFAULT 'failureonly'"` +} + +func (*NotificationSettings) TableName() string { + return "user_notification_settings" +} + +func AddFineGrainedActionsNotificationSettings(x *xorm.Engine) error { + if err := x.Sync(&NotificationSettings{}); err != nil { + return err + } + + settings := make([]NotificationSettings, 0, 100) + + type User struct { + ID int64 `xorm:"pk autoincr"` + } + + if err := db.Iterate(context.Background(), nil, func(ctx context.Context, user *User) error { + settings = append(settings, NotificationSettings{ + UserID: user.ID, + Actions: user_model.NotificationActionsFailureOnly, + }) + if len(settings) >= 100 { + _, err := x.Insert(&settings) + if err != nil { + return err + } + settings = settings[:0] + } + return nil + }); err != nil { + return err + } + + if len(settings) > 0 { + if _, err := x.Insert(&settings); err != nil { + return err + } + } + + return nil +} diff --git a/models/user/user.go b/models/user/user.go index c362cbc6d2..7074cdba6d 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -798,6 +798,12 @@ func createUser(ctx context.Context, u *User, meta *Meta, createdByAdmin bool, o return err } + if err := db.Insert(ctx, &NotificationSettings{ + UserID: u.ID, + }); err != nil { + return err + } + return committer.Commit() } diff --git a/models/user/user_notification.go b/models/user/user_notification.go new file mode 100644 index 0000000000..37f30f9c6c --- /dev/null +++ b/models/user/user_notification.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +const ( + NotificationActionsAll = "all" + NotificationActionsFailureOnly = "failureonly" + NotificationActionsDisabled = "disabled" +) + +type NotificationSettings struct { + UserID int64 `xorm:"pk"` + User *User `xorm:"-"` + Actions string `xorm:"NOT NULL DEFAULT 'failureonly'"` +} + +func (NotificationSettings) TableName() string { + return "user_notification_settings" +} + +func init() { + db.RegisterModel(new(NotificationSettings)) +} + +// GetUserNotificationSettings returns a user's fine-grained notification preference +func GetUserNotificationSettings(ctx context.Context, userID int64) (*NotificationSettings, error) { + settings := &NotificationSettings{} + if has, err := db.GetEngine(ctx).Where("user_id=?", userID).Get(settings); err != nil { + return nil, err + } else if !has { + return nil, nil + } + user, err := GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + settings.User = user + return settings, nil +} + +func UpdateUserNotificationSettings(ctx context.Context, settings *NotificationSettings) error { + _, err := db.GetEngine(ctx).Where("user_id = ?", settings.UserID). + Update(&NotificationSettings{ + Actions: settings.Actions, + }) + return err +} diff --git a/models/user/user_notification_test.go b/models/user/user_notification_test.go new file mode 100644 index 0000000000..396b03fd4d --- /dev/null +++ b/models/user/user_notification_test.go @@ -0,0 +1,37 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestUserNotificationSettings(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + settings, err := GetUserNotificationSettings(db.DefaultContext, 1) + assert.NoError(t, err) + assert.Equal(t, NotificationActionsFailureOnly, settings.Actions) + + assert.NoError(t, UpdateUserNotificationSettings(db.DefaultContext, &NotificationSettings{ + UserID: 1, + Actions: NotificationActionsAll, + })) + settings, err = GetUserNotificationSettings(db.DefaultContext, 1) + assert.NoError(t, err) + assert.Equal(t, NotificationActionsAll, settings.Actions) + + assert.NoError(t, UpdateUserNotificationSettings(db.DefaultContext, &NotificationSettings{ + UserID: 1, + Actions: NotificationActionsDisabled, + })) + settings, err = GetUserNotificationSettings(db.DefaultContext, 1) + assert.NoError(t, err) + assert.Equal(t, NotificationActionsDisabled, settings.Actions) +} diff --git a/services/user/delete.go b/services/user/delete.go index 39c6ef052d..49f8403c7e 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -95,6 +95,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &user_model.Blocking{BlockerID: u.ID}, &user_model.Blocking{BlockeeID: u.ID}, &actions_model.ActionRunnerToken{OwnerID: u.ID}, + &user_model.NotificationSettings{UserID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/services/user/update.go b/services/user/update.go index d7354542bf..2aef5c1daa 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -244,3 +244,15 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions } return nil } + +type UpdateNotificationSettingsOptions struct { + Actions optional.Option[string] +} + +func UpdateNotificationSettings(ctx context.Context, settings *user_model.NotificationSettings, opts *UpdateNotificationSettingsOptions) error { + if opts.Actions.Has() { + settings.Actions = opts.Actions.Value() + } + + return user_model.UpdateUserNotificationSettings(ctx, settings) +} diff --git a/services/user/update_test.go b/services/user/update_test.go index 27513e8040..599c3fd2b6 100644 --- a/services/user/update_test.go +++ b/services/user/update_test.go @@ -122,3 +122,25 @@ func TestUpdateAuth(t *testing.T) { Password: optional.Some("aaaa"), }), password_module.ErrMinLength) } + +func TestUpdateNotificationSettings(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + settings := &user_model.NotificationSettings{UserID: 2} + exists, err := db.GetEngine(db.DefaultContext).Get(settings) + assert.NoError(t, err) + assert.True(t, exists) + settingsCopy := *settings + + assert.NoError(t, UpdateNotificationSettings(db.DefaultContext, settings, &UpdateNotificationSettingsOptions{ + Actions: optional.Some(user_model.NotificationActionsAll), + })) + assert.Equal(t, user_model.NotificationActionsAll, settings.Actions) + assert.NotEqual(t, settingsCopy.Actions, settings.Actions) + + assert.NoError(t, UpdateNotificationSettings(db.DefaultContext, settings, &UpdateNotificationSettingsOptions{ + Actions: optional.Some(user_model.NotificationActionsDisabled), + })) + assert.Equal(t, user_model.NotificationActionsDisabled, settings.Actions) + assert.NotEqual(t, settingsCopy.Actions, settings.Actions) +}