From f8d9a8046faebd5713c231ef8a25b0c1782721c9 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 28 Nov 2025 16:50:59 +0000 Subject: [PATCH] feat: add notification warning alert to Tasks page (#20900) ## Problem Users may not realize that task notifications are disabled by default. To improve awareness, we show a warning alert on the Tasks page when all task notifications are disabled. **Alert visibility logic:** - Shows when **all** task notification templates (Task Working, Task Idle, Task Completed, Task Failed) are disabled - Can be dismissed by the user, which stores the dismissal in the user preferences API - If the user later enables any task notification in Account Settings, the dismissal state is cleared so the alert will show again if they disable all notifications in the future Screenshot 2025-11-25 at 17 48 17 ## Changes - Added a warning alert to the Tasks page when all task notifications are disabled - Introduced new `/users/{user}/preferences` endpoint to manage user preferences (stored in `user_configs` table) - Alert is dismissible and stores the dismissal state via the new user preferences API endpoint - Enabling any task notification in Account Settings clears the dismissal state via the preferences API - Added comprehensive Storybook stories for both TasksPage and NotificationsPage to test all alert visibility states and interactions Closes: https://github.com/coder/internal/issues/1089 --- coderd/apidoc/docs.go | 94 ++++++++++++ coderd/apidoc/swagger.json | 84 +++++++++++ coderd/coderd.go | 2 + coderd/database/dbauthz/dbauthz.go | 22 +++ coderd/database/dbauthz/dbauthz_test.go | 16 ++ coderd/database/dbmetrics/querymetrics.go | 14 ++ coderd/database/dbmock/dbmock.go | 30 ++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 44 ++++++ coderd/database/queries/users.sql | 23 +++ coderd/users.go | 68 +++++++++ coderd/users_test.go | 87 +++++++++-- codersdk/users.go | 36 +++++ docs/reference/api/schemas.md | 28 ++++ docs/reference/api/users.md | 84 +++++++++++ site/src/api/api.ts | 13 ++ site/src/api/queries/users.ts | 29 ++++ site/src/api/typesGenerated.ts | 10 ++ site/src/modules/notifications/utils.tsx | 30 ++++ .../src/pages/TasksPage/TasksPage.stories.tsx | 138 +++++++++++++++++- site/src/pages/TasksPage/TasksPage.tsx | 64 +++++++- .../NotificationsPage.stories.tsx | 68 ++++++++- .../NotificationsPage/NotificationsPage.tsx | 53 +++---- site/src/testHelpers/entities.ts | 29 +++- 24 files changed, 1028 insertions(+), 40 deletions(-) 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, }, ];