diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 186b54716d..f80959192d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8387,6 +8387,84 @@ const docTemplate = `{ } } }, + "/users/{user}/preferences": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user preference settings", + "operationId": "get-user-preference-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserPreferenceSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user preference settings", + "operationId": "update-user-preference-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "New preference settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserPreferenceSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserPreferenceSettings" + } + } + } + } + }, "/users/{user}/profile": { "put": { "security": [ @@ -19254,6 +19332,14 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserPreferenceSettingsRequest": { + "type": "object", + "properties": { + "task_notification_alert_dismissed": { + "type": "boolean" + } + } + }, "codersdk.UpdateUserProfileRequest": { "type": "object", "required": [ @@ -19639,6 +19725,14 @@ const docTemplate = `{ } } }, + "codersdk.UserPreferenceSettings": { + "type": "object", + "properties": { + "task_notification_alert_dismissed": { + "type": "boolean" + } + } + }, "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 097aa188f3..8d54e2b310 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7418,6 +7418,74 @@ } } }, + "/users/{user}/preferences": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get user preference settings", + "operationId": "get-user-preference-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserPreferenceSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Update user preference settings", + "operationId": "update-user-preference-settings", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "New preference settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserPreferenceSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserPreferenceSettings" + } + } + } + } + }, "/users/{user}/profile": { "put": { "security": [ @@ -17660,6 +17728,14 @@ } } }, + "codersdk.UpdateUserPreferenceSettingsRequest": { + "type": "object", + "properties": { + "task_notification_alert_dismissed": { + "type": "boolean" + } + } + }, "codersdk.UpdateUserProfileRequest": { "type": "object", "required": ["username"], @@ -18020,6 +18096,14 @@ } } }, + "codersdk.UserPreferenceSettings": { + "type": "object", + "properties": { + "task_notification_alert_dismissed": { + "type": "boolean" + } + } + }, "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 641fd4e7f3..e79a2226ba 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1336,6 +1336,8 @@ func New(options *Options) *API { }) r.Get("/appearance", api.userAppearanceSettings) r.Put("/appearance", api.putUserAppearanceSettings) + r.Get("/preferences", api.userPreferenceSettings) + r.Put("/preferences", api.putUserPreferenceSettings) r.Route("/password", func(r chi.Router) { r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute)) r.Put("/", api.putUserPassword) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2c29b90431..a4d801512e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3431,6 +3431,17 @@ func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserS return q.db.GetUserStatusCounts(ctx, arg) } +func (q *querier) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) { + user, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return false, err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, user); err != nil { + return false, err + } + return q.db.GetUserTaskNotificationAlertDismissed(ctx, userID) +} + func (q *querier) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { u, err := q.db.GetUserByID(ctx, userID) if err != nil { @@ -5464,6 +5475,17 @@ func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserS return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserStatus)(ctx, arg) } +func (q *querier) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) { + user, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return false, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, user); err != nil { + return false, err + } + return q.db.UpdateUserTaskNotificationAlertDismissed(ctx, arg) +} + func (q *querier) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d14429a105..e70da620e1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "reflect" + "strconv" "testing" "time" @@ -1477,6 +1478,21 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserTerminalFont(gomock.Any(), arg).Return(uc, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) })) + s.Run("GetUserTaskNotificationAlertDismissed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserTaskNotificationAlertDismissed(gomock.Any(), u.ID).Return(false, nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns(false) + })) + s.Run("UpdateUserTaskNotificationAlertDismissed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + userConfig := database.UserConfig{UserID: user.ID, Key: "task_notification_alert_dismissed", Value: "false"} + userConfigValue, _ := strconv.ParseBool(userConfig.Value) + arg := database.UpdateUserTaskNotificationAlertDismissedParams{UserID: user.ID, TaskNotificationAlertDismissed: userConfigValue} + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().UpdateUserTaskNotificationAlertDismissed(gomock.Any(), arg).Return(false, nil).AnyTimes() + check.Args(arg).Asserts(user, policy.ActionUpdatePersonal).Returns(userConfigValue) + })) s.Run("UpdateUserStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) arg := database.UpdateUserStatusParams{ID: u.ID, Status: u.Status, UpdatedAt: u.UpdatedAt} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 89269b295c..cdad3598b3 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1845,6 +1845,13 @@ func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetUserTaskNotificationAlertDismissed(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserTaskNotificationAlertDismissed").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { start := time.Now() r0, r1 := m.s.GetUserTerminalFont(ctx, userID) @@ -3350,6 +3357,13 @@ func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.Up return user, err } +func (m queryMetricsStore) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserTaskNotificationAlertDismissed(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserTaskNotificationAlertDismissed").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) { start := time.Now() r0, r1 := m.s.UpdateUserTerminalFont(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ce50bf87d2..03de5508e5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3942,6 +3942,21 @@ func (mr *MockStoreMockRecorder) GetUserStatusCounts(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusCounts", reflect.TypeOf((*MockStore)(nil).GetUserStatusCounts), ctx, arg) } +// GetUserTaskNotificationAlertDismissed mocks base method. +func (m *MockStore) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserTaskNotificationAlertDismissed", ctx, userID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserTaskNotificationAlertDismissed indicates an expected call of GetUserTaskNotificationAlertDismissed. +func (mr *MockStoreMockRecorder) GetUserTaskNotificationAlertDismissed(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTaskNotificationAlertDismissed", reflect.TypeOf((*MockStore)(nil).GetUserTaskNotificationAlertDismissed), ctx, userID) +} + // GetUserTerminalFont mocks base method. func (m *MockStore) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) { m.ctrl.T.Helper() @@ -7174,6 +7189,21 @@ func (mr *MockStoreMockRecorder) UpdateUserStatus(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockStore)(nil).UpdateUserStatus), ctx, arg) } +// UpdateUserTaskNotificationAlertDismissed mocks base method. +func (m *MockStore) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg database.UpdateUserTaskNotificationAlertDismissedParams) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserTaskNotificationAlertDismissed", ctx, arg) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserTaskNotificationAlertDismissed indicates an expected call of UpdateUserTaskNotificationAlertDismissed. +func (mr *MockStoreMockRecorder) UpdateUserTaskNotificationAlertDismissed(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserTaskNotificationAlertDismissed", reflect.TypeOf((*MockStore)(nil).UpdateUserTaskNotificationAlertDismissed), ctx, arg) +} + // UpdateUserTerminalFont mocks base method. func (m *MockStore) UpdateUserTerminalFont(ctx context.Context, arg database.UpdateUserTerminalFontParams) (database.UserConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5c249f3bdf..7997d7f085 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -447,6 +447,7 @@ type sqlcQuerier interface { // We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such, // the result shows the total number of users in each status on any particular day. GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error) + GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) @@ -714,6 +715,7 @@ type sqlcQuerier interface { UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) + UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8ae4c563e4..329a23dd70 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -16324,6 +16324,23 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context, includeSystem bool) (int6 return count, err } +const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one +SELECT + value::boolean as task_notification_alert_dismissed +FROM + user_configs +WHERE + user_id = $1 + AND key = 'preference_task_notification_alert_dismissed' +` + +func (q *sqlQuerier) GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) { + row := q.db.QueryRowContext(ctx, getUserTaskNotificationAlertDismissed, userID) + var task_notification_alert_dismissed bool + err := row.Scan(&task_notification_alert_dismissed) + return task_notification_alert_dismissed, err +} + const getUserTerminalFont = `-- name: GetUserTerminalFont :one SELECT value as terminal_font @@ -17076,6 +17093,33 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP return i, err } +const updateUserTaskNotificationAlertDismissed = `-- name: UpdateUserTaskNotificationAlertDismissed :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'preference_task_notification_alert_dismissed', ($2::boolean)::text) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'preference_task_notification_alert_dismissed' +RETURNING value::boolean AS task_notification_alert_dismissed +` + +type UpdateUserTaskNotificationAlertDismissedParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + TaskNotificationAlertDismissed bool `db:"task_notification_alert_dismissed" json:"task_notification_alert_dismissed"` +} + +func (q *sqlQuerier) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) { + row := q.db.QueryRowContext(ctx, updateUserTaskNotificationAlertDismissed, arg.UserID, arg.TaskNotificationAlertDismissed) + var task_notification_alert_dismissed bool + err := row.Scan(&task_notification_alert_dismissed) + return task_notification_alert_dismissed, err +} + const updateUserTerminalFont = `-- name: UpdateUserTerminalFont :one INSERT INTO user_configs (user_id, key, value) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 0b6e52d6bc..889e99a330 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -168,6 +168,29 @@ WHERE user_configs.user_id = @user_id AND user_configs.key = 'terminal_font' RETURNING *; +-- name: GetUserTaskNotificationAlertDismissed :one +SELECT + value::boolean as task_notification_alert_dismissed +FROM + user_configs +WHERE + user_id = @user_id + AND key = 'preference_task_notification_alert_dismissed'; + +-- name: UpdateUserTaskNotificationAlertDismissed :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'preference_task_notification_alert_dismissed', (@task_notification_alert_dismissed::boolean)::text) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = @task_notification_alert_dismissed +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'preference_task_notification_alert_dismissed' +RETURNING value::boolean AS task_notification_alert_dismissed; + -- name: UpdateUserRoles :one UPDATE users diff --git a/coderd/users.go b/coderd/users.go index 3ac05ff768..94d4dece24 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1077,6 +1077,74 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques }) } +// @Summary Get user preference settings +// @ID get-user-preference-settings +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.UserPreferenceSettings +// @Router /users/{user}/preferences [get] +func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + taskAlertDismissed, err := api.Database.GetUserTaskNotificationAlertDismissed(ctx, user.ID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading user preference settings.", + Detail: err.Error(), + }) + return + } + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{ + TaskNotificationAlertDismissed: taskAlertDismissed, + }) +} + +// @Summary Update user preference settings +// @ID update-user-preference-settings +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Param request body codersdk.UpdateUserPreferenceSettingsRequest true "New preference settings" +// @Success 200 {object} codersdk.UserPreferenceSettings +// @Router /users/{user}/preferences [put] +func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + var params codersdk.UpdateUserPreferenceSettingsRequest + if !httpapi.Read(ctx, rw, r, ¶ms) { + return + } + + updatedTaskAlertDismissed, err := api.Database.UpdateUserTaskNotificationAlertDismissed(ctx, database.UpdateUserTaskNotificationAlertDismissedParams{ + UserID: user.ID, + TaskNotificationAlertDismissed: params.TaskNotificationAlertDismissed, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating user task notification alert dismissed.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{ + TaskNotificationAlertDismissed: updatedTaskAlertDismissed, + }) +} + func isValidFontName(font codersdk.TerminalFontName) bool { return slices.Contains(codersdk.TerminalFontNames, font) } diff --git a/coderd/users_test.go b/coderd/users_test.go index b8d74272ed..4691165930 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -2192,16 +2192,16 @@ func TestUserTerminalFont(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() // given - initial, err := client.GetUserAppearanceSettings(ctx, "me") + initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) require.NoError(t, err) require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont) // when - updated, err := client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{ + updated, err := client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ ThemePreference: "light", TerminalFont: "fira-code", }) @@ -2218,16 +2218,16 @@ func TestUserTerminalFont(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() // given - initial, err := client.GetUserAppearanceSettings(ctx, "me") + initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) require.NoError(t, err) require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont) // when - _, err = client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{ + _, err = client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ ThemePreference: "light", TerminalFont: "foobar", }) @@ -2243,16 +2243,16 @@ func TestUserTerminalFont(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() // given - initial, err := client.GetUserAppearanceSettings(ctx, "me") + initial, err := client.GetUserAppearanceSettings(ctx, codersdk.Me) require.NoError(t, err) require.Equal(t, codersdk.TerminalFontName(""), initial.TerminalFont) // when - _, err = client.UpdateUserAppearanceSettings(ctx, "me", codersdk.UpdateUserAppearanceSettingsRequest{ + _, err = client.UpdateUserAppearanceSettings(ctx, codersdk.Me, codersdk.UpdateUserAppearanceSettingsRequest{ ThemePreference: "light", TerminalFont: "", }) @@ -2262,6 +2262,75 @@ func TestUserTerminalFont(t *testing.T) { }) } +func TestUserTaskNotificationAlertDismissed(t *testing.T) { + t.Parallel() + + t.Run("defaults to false", func(t *testing.T) { + t.Parallel() + + adminClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // When: getting user preference settings for a user + settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me) + require.NoError(t, err) + + // Then: the task notification alert dismissed should default to false + require.False(t, settings.TaskNotificationAlertDismissed) + }) + + t.Run("update to true", func(t *testing.T) { + t.Parallel() + + adminClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // When: user dismisses the task notification alert + updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + TaskNotificationAlertDismissed: true, + }) + require.NoError(t, err) + + // Then: the setting is updated to true + require.True(t, updated.TaskNotificationAlertDismissed) + }) + + t.Run("update to false", func(t *testing.T) { + t.Parallel() + + adminClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // Given: user has dismissed the task notification alert + _, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + TaskNotificationAlertDismissed: true, + }) + require.NoError(t, err) + + // When: the task notification alert dismissal is cleared + // (e.g., when user enables a task notification in the UI settings) + updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + TaskNotificationAlertDismissed: false, + }) + require.NoError(t, err) + + // Then: the setting is updated to false + require.False(t, updated.TaskNotificationAlertDismissed) + }) +} + func TestWorkspacesByUser(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index 44464f9476..1bf09370d9 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -217,6 +217,14 @@ type UpdateUserAppearanceSettingsRequest struct { TerminalFont TerminalFontName `json:"terminal_font" validate:"required"` } +type UserPreferenceSettings struct { + TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` +} + +type UpdateUserPreferenceSettingsRequest struct { + TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` +} + type UpdateUserPasswordRequest struct { OldPassword string `json:"old_password" validate:""` Password string `json:"password" validate:"required"` @@ -514,6 +522,34 @@ func (c *Client) UpdateUserAppearanceSettings(ctx context.Context, user string, return resp, json.NewDecoder(res.Body).Decode(&resp) } +// GetUserPreferenceSettings fetches the preference settings for a user. +func (c *Client) GetUserPreferenceSettings(ctx context.Context, user string) (UserPreferenceSettings, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/preferences", user), nil) + if err != nil { + return UserPreferenceSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserPreferenceSettings{}, ReadBodyAsError(res) + } + var resp UserPreferenceSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateUserPreferenceSettings updates the preference settings for a user. +func (c *Client) UpdateUserPreferenceSettings(ctx context.Context, user string, req UpdateUserPreferenceSettingsRequest) (UserPreferenceSettings, error) { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/preferences", user), req) + if err != nil { + return UserPreferenceSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserPreferenceSettings{}, ReadBodyAsError(res) + } + var resp UserPreferenceSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // UpdateUserPassword updates a user password. // It calls PUT /users/{user}/password func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f3347ab323..7f88558c75 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9358,6 +9358,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `old_password` | string | false | | | | `password` | string | true | | | +## codersdk.UpdateUserPreferenceSettingsRequest + +```json +{ + "task_notification_alert_dismissed": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|---------|----------|--------------|-------------| +| `task_notification_alert_dismissed` | boolean | false | | | + ## codersdk.UpdateUserProfileRequest ```json @@ -9846,6 +9860,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `name` | string | false | | | | `value` | string | false | | | +## codersdk.UserPreferenceSettings + +```json +{ + "task_notification_alert_dismissed": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|---------|----------|--------------|-------------| +| `task_notification_alert_dismissed` | boolean | false | | | + ## codersdk.UserQuietHoursScheduleConfig ```json diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 857d619398..c69c57af85 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -1241,6 +1241,90 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/password \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user preference settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/preferences` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "task_notification_alert_dismissed": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserPreferenceSettings](schemas.md#codersdkuserpreferencesettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update user preference settings + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/preferences` + +> Body parameter + +```json +{ + "task_notification_alert_dismissed": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------------------------------------------------|----------|-------------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.UpdateUserPreferenceSettingsRequest](schemas.md#codersdkupdateuserpreferencesettingsrequest) | true | New preference settings | + +### Example responses + +> 200 Response + +```json +{ + "task_notification_alert_dismissed": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserPreferenceSettings](schemas.md#codersdkuserpreferencesettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update user profile ### Code samples diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ee97f4d64f..6f6edf44ea 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1483,6 +1483,19 @@ class ApiMethods { return response.data; }; + getUserPreferenceSettings = + async (): Promise => { + const response = await this.axios.get("/api/v2/users/me/preferences"); + return response.data; + }; + + updateUserPreferenceSettings = async ( + req: TypesGen.UpdateUserPreferenceSettingsRequest, + ): Promise => { + const response = await this.axios.put("/api/v2/users/me/preferences", req); + return response.data; + }; + getUserQuietHoursSchedule = async ( userId: TypesGen.User["id"], ): Promise => { diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 31a0302c94..e6ec7e3364 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -6,9 +6,11 @@ import type { RequestOneTimePasscodeRequest, UpdateUserAppearanceSettingsRequest, UpdateUserPasswordRequest, + UpdateUserPreferenceSettingsRequest, UpdateUserProfileRequest, User, UserAppearanceSettings, + UserPreferenceSettings, UsersRequest, } from "api/typesGenerated"; import { @@ -277,6 +279,33 @@ export const updateAppearanceSettings = ( }; }; +const myPreferencesKey = ["me", "preferences"]; + +export const preferenceSettings = + (): UseQueryOptions => { + return { + queryKey: myPreferencesKey, + queryFn: () => API.getUserPreferenceSettings(), + }; + }; + +export const updatePreferenceSettings = ( + queryClient: QueryClient, +): UseMutationOptions< + UserPreferenceSettings, + unknown, + UpdateUserPreferenceSettingsRequest, + unknown +> => { + return { + mutationFn: (req) => API.updateUserPreferenceSettings(req), + onSuccess: async () => + await queryClient.invalidateQueries({ + queryKey: myPreferencesKey, + }), + }; +}; + export const requestOneTimePassword = () => { return { mutationFn: (req: RequestOneTimePasscodeRequest) => diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d31b069dce..456d1a737a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5479,6 +5479,11 @@ export interface UpdateUserPasswordRequest { readonly password: string; } +// From codersdk/users.go +export interface UpdateUserPreferenceSettingsRequest { + readonly task_notification_alert_dismissed: boolean; +} + // From codersdk/users.go export interface UpdateUserProfileRequest { readonly username: string; @@ -5707,6 +5712,11 @@ export interface UserParameter { readonly value: string; } +// From codersdk/users.go +export interface UserPreferenceSettings { + readonly task_notification_alert_dismissed: boolean; +} + // From codersdk/deployment.go export interface UserQuietHoursScheduleConfig { readonly default_schedule: string; diff --git a/site/src/modules/notifications/utils.tsx b/site/src/modules/notifications/utils.tsx index 273a68e013..5e435d5a75 100644 --- a/site/src/modules/notifications/utils.tsx +++ b/site/src/modules/notifications/utils.tsx @@ -1,3 +1,7 @@ +import type { + NotificationPreference, + NotificationTemplate, +} from "api/typesGenerated"; import { MailIcon, WebhookIcon } from "lucide-react"; // TODO: This should be provided by the auto generated types from codersdk @@ -26,3 +30,29 @@ export const castNotificationMethod = (value: string) => { )}`, ); }; + +export function isTaskNotification(tmpl: NotificationTemplate): boolean { + return tmpl.group === "Task Events"; +} + +// Determines if a notification is disabled based on user preferences and system defaults +// A notification is considered disabled if: +// 1. It's NOT enabled by default AND the user hasn't set any preference (undefined), OR +// 2. The user has explicitly disabled it in their preferences +// Returns true if disabled, false if enabled +export function notificationIsDisabled( + disabledPreferences: Record, + tmpl: NotificationTemplate, +): boolean { + return Boolean( + (!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) || + disabledPreferences[tmpl.id], + ); +} + +// Transforms an array of NotificationPreference objects into a map +// where the key is the template ID and the value is whether it's disabled +// Example: [{ id: "abc", disabled: true }, { id: "def", disabled: false }] +export function selectDisabledPreferences(data: NotificationPreference[]) { + return Object.fromEntries(data.map((pref) => [pref.id, pref.disabled])); +} diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 67f350c8aa..443234c195 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -1,6 +1,7 @@ import { MockDisplayNameTasks, MockInitializingTasks, + MockSystemNotificationTemplates, MockTasks, MockTemplate, MockUserOwner, @@ -13,9 +14,9 @@ import { } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; +import { getTemplatesQueryKey } from "api/queries/templates"; import { MockUsers } from "pages/UsersPage/storybookData/users"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; -import { getTemplatesQueryKey } from "../../api/queries/templates"; import TasksPage from "./TasksPage"; const meta: Meta = { @@ -361,3 +362,138 @@ export const BatchActionsDropdownOpen: Story = { }); }, }; + +export const AllTaskNotificationsDisabledAlertVisible: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + { + // User notification preferences: empty because user hasn't changed defaults + // Task notifications are disabled by default (enabled_by_default: false) + key: ["users", MockUserOwner.id, "notifications", "preferences"], + data: [], + }, + { + // System notification templates: includes task notifications with enabled_by_default: false + key: ["notifications", "templates", "system"], + data: MockSystemNotificationTemplates, + }, + { + // User preferences: alert NOT dismissed + key: ["me", "preferences"], + data: { task_notification_alert_dismissed: false }, + }, + ], + }, +}; + +export const AllTaskNotificationsDisabledAlertDismissed: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + { + // User notification preferences: empty because user hasn't changed defaults + // Task notifications are disabled by default (enabled_by_default: false) + key: ["users", MockUserOwner.id, "notifications", "preferences"], + data: [], + }, + { + // System notification templates: includes task notifications with enabled_by_default: false + key: ["notifications", "templates", "system"], + data: MockSystemNotificationTemplates, + }, + { + // User preferences: alert IS dismissed + key: ["me", "preferences"], + data: { task_notification_alert_dismissed: true }, + }, + ], + }, +}; + +export const OneTaskNotificationEnabledAlertHidden: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + { + // User has explicitly enabled one task notification (Task Working) + // Since at least one task notification is enabled, the warning alert should not appear + key: ["users", MockUserOwner.id, "notifications", "preferences"], + data: [ + { + id: "bd4b7168-d05e-4e19-ad0f-3593b77aa90f", // Task Working + disabled: false, + updated_at: new Date().toISOString(), + }, + ], + }, + { + // System notification templates: includes task notifications with enabled_by_default: false + key: ["notifications", "templates", "system"], + data: MockSystemNotificationTemplates, + }, + { + // User preferences: doesn't matter since alert shouldn't show anyway + key: ["me", "preferences"], + data: { task_notification_alert_dismissed: false }, + }, + ], + }, +}; + +export const AllTaskNotificationsExplicitlyDisabledAlertVisible: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + { + // User has explicitly disabled a task notification + key: ["users", MockUserOwner.id, "notifications", "preferences"], + data: [ + { + id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", // Task Idle + disabled: true, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + ], + }, + { + // System notification templates: includes task notifications with enabled_by_default: false + key: ["notifications", "templates", "system"], + data: MockSystemNotificationTemplates, + }, + { + // User preferences: alert NOT dismissed + key: ["me", "preferences"], + data: { task_notification_alert_dismissed: false }, + }, + ], + }, +}; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 66aebdac26..04e657f1a1 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,7 +1,15 @@ import { API } from "api/api"; +import { + systemNotificationTemplates, + userNotificationPreferences, +} from "api/queries/notifications"; import { templates } from "api/queries/templates"; - +import { + preferenceSettings, + updatePreferenceSettings, +} from "api/queries/users"; import type { TasksFilter } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; import { Badge } from "components/Badge/Badge"; import { Button, type ButtonProps } from "components/Button/Button"; import { @@ -10,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; +import { Link } from "components/Link/Link"; import { Margins } from "components/Margins/Margins"; import { PageHeader, @@ -22,9 +31,14 @@ import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { ChevronDownIcon, TrashIcon } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; +import { + isTaskNotification, + notificationIsDisabled, + selectDisabledPreferences, +} from "modules/notifications/utils"; import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt"; import { type FC, useState } from "react"; -import { useQuery } from "react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; @@ -96,10 +110,56 @@ const TasksPage: FC = () => { (t) => t.workspace_id !== null, ).length; + // Fetch notification preferences and templates + const [disabledPreferencesQuery, systemTemplatesQuery] = useQueries({ + queries: [ + { + ...userNotificationPreferences(user.id), + select: selectDisabledPreferences, + }, + systemNotificationTemplates(), + ], + }); + + const disabledPreferences = disabledPreferencesQuery.data ?? {}; + + // Check if ALL task notifications are disabled + // Returns true only when all task notification templates are disabled. + // If even one is enabled, returns false and the warning won't show. + const allTaskNotificationsDisabled = systemTemplatesQuery.data + ?.filter(isTaskNotification) + .every((template) => notificationIsDisabled(disabledPreferences, template)); + + const queryClient = useQueryClient(); + const preferencesQuery = useQuery(preferenceSettings()); + const updatePreferencesMutation = useMutation( + updatePreferenceSettings(queryClient), + ); + + const taskNotificationAlertDismissed = + preferencesQuery.data?.task_notification_alert_dismissed ?? false; + return ( <> {pageTitle("AI Tasks")} + {allTaskNotificationsDisabled && !taskNotificationAlertDismissed && ( +
+ { + updatePreferencesMutation.mutate({ + task_notification_alert_dismissed: true, + }); + }} + > + Your notifications for tasks status changes are disabled. Go to{" "} + Account Settings to + change it. + +
+ )} Tasks Automate tasks with AI diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index 166531eb69..5b0c508726 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -18,7 +18,7 @@ import { systemNotificationTemplatesKey, userNotificationPreferencesKey, } from "api/queries/notifications"; -import { expect, spyOn, userEvent, within } from "storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import NotificationsPage from "./NotificationsPage"; @@ -185,3 +185,69 @@ export const DisableInvalidTemplate: Story = { await within(document.body).findByText("Error disabling notification"); }, }; + +export const EnablingTaskNotificationClearsAlertDismissal: Story = { + parameters: { + queries: [ + { + // User notification preferences: empty because user hasn't changed defaults + // Task notifications are disabled by default (enabled_by_default: false) + key: ["users", MockUserOwner.id, "notifications", "preferences"], + data: [], + }, + { + // System notification templates: includes task notifications with enabled_by_default: false + key: ["notifications", "templates", "system"], + data: MockSystemNotificationTemplates, + }, + { + key: customNotificationTemplatesKey, + data: MockCustomNotificationTemplates, + }, + { + key: notificationDispatchMethodsKey, + data: MockNotificationMethodsResponse, + }, + { + // User preferences: alert was previously dismissed + key: ["me", "preferences"], + data: { task_notification_alert_dismissed: true }, + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + // Mock the API call to update notification preferences + spyOn(API, "putUserNotificationPreferences").mockResolvedValue([ + { + id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", // Task Idle + disabled: false, + updated_at: new Date().toISOString(), + }, + ]); + + // Mock the user preferences update to verify the alert dismissal is cleared + const updatePreferencesSpy = spyOn( + API, + "updateUserPreferenceSettings", + ).mockResolvedValue({ + task_notification_alert_dismissed: false, + }); + + await step("Enable Task Idle notification", async () => { + // Find the Task Idle checkbox by its label text + const taskIdleToggle = canvas.getByLabelText("Task Idle"); + + // Click to enable it + await userEvent.click(taskIdleToggle); + + // Verify the preferences API was called to clear the alert dismissal + await waitFor(() => { + expect(updatePreferencesSpy).toHaveBeenCalledWith({ + task_notification_alert_dismissed: false, + }); + }); + }); + }, +}; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 730a3e8b04..7af3401d8f 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -15,10 +15,11 @@ import { updateUserNotificationPreferences, userNotificationPreferences, } from "api/queries/notifications"; -import type { - NotificationPreference, - NotificationTemplate, -} from "api/typesGenerated"; +import { + preferenceSettings, + updatePreferenceSettings, +} from "api/queries/users"; +import type { NotificationTemplate } from "api/typesGenerated"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; @@ -30,12 +31,15 @@ import { import { useAuthenticated } from "hooks"; import { castNotificationMethod, + isTaskNotification, methodIcons, methodLabels, + notificationIsDisabled, + selectDisabledPreferences, } from "modules/notifications/utils"; import type { Permissions } from "modules/permissions"; import { type FC, Fragment, useEffect } from "react"; -import { useMutation, useQueries, useQueryClient } from "react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; import { pageTitle } from "utils/page"; import { Section } from "../Section"; @@ -103,6 +107,11 @@ const NotificationsPage: FC = () => { ...customTemplatesByGroup.data, }; + const preferencesQuery = useQuery(preferenceSettings()); + const updatePreferencesMutation = useMutation( + updatePreferenceSettings(queryClient), + ); + return ( <> {pageTitle("Notifications Settings")} @@ -185,6 +194,20 @@ const NotificationsPage: FC = () => { [tmpl.id]: !checked, }, }); + + // Clear the Tasks page warning dismissal when enabling a task notification + // This ensures that if the user disables task notifications again later, + // they will see the warning alert again. + if ( + isTaskNotification(tmpl) && + checked && + preferencesQuery.data + ) { + updatePreferencesMutation.mutate({ + task_notification_alert_dismissed: false, + }); + } + displaySuccess( "Notification preferences updated", ); @@ -250,26 +273,6 @@ function canSeeNotificationGroup( } } -function notificationIsDisabled( - disabledPreferences: Record, - tmpl: NotificationTemplate, -): boolean { - return ( - (!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) || - !!disabledPreferences[tmpl.id] - ); -} - -function selectDisabledPreferences(data: NotificationPreference[]) { - return data.reduce( - (acc, pref) => { - acc[pref.id] = pref.disabled; - return acc; - }, - {} as Record, - ); -} - const styles = { listHeader: (theme) => ({ background: theme.palette.background.paper, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dcf6e720b2..89e3a78966 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4695,6 +4695,31 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = kind: "system", enabled_by_default: true, }, + { + id: "8c5a4d12-9f7e-4b3a-a1c8-6e4f2d9b5a7c", + name: "Task Completed", + title_template: "Task '{{.Labels.workspace}}' completed", + body_template: "The task '{{.Labels.task}}' has completed successfully.", + actions: + '[{"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}", "label": "View task"}, {"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}", "label": "View workspace"}]', + group: "Task Events", + method: "", + kind: "system", + enabled_by_default: false, + }, + { + id: "3b7e8f1a-4c2d-49a6-b5e9-7f3a1c8d6b4e", + name: "Task Failed", + title_template: "Task '{{.Labels.workspace}}' failed", + body_template: + "The task '{{.Labels.task}}' has failed. Check the logs for more details.", + actions: + '[{"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}", "label": "View task"}, {"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}", "label": "View workspace"}]', + group: "Task Events", + method: "", + kind: "system", + enabled_by_default: false, + }, { id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", name: "Task Idle", @@ -4705,7 +4730,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = group: "Task Events", method: "", kind: "system", - enabled_by_default: true, + enabled_by_default: false, }, { id: "bd4b7168-d05e-4e19-ad0f-3593b77aa90f", @@ -4718,7 +4743,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = group: "Task Events", method: "", kind: "system", - enabled_by_default: true, + enabled_by_default: false, }, ];