diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1ce9c6ffda204..cda35b2f2bfd2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1558,7 +1558,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "General" + "Notifications" ], "summary": "Get notifications settings", "operationId": "get-notifications-settings", @@ -1584,7 +1584,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "General" + "Notifications" ], "summary": "Update notifications settings", "operationId": "update-notifications-settings", @@ -1612,6 +1612,56 @@ const docTemplate = `{ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, + "/notifications/templates/{notification_template}/method": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update notification template dispatch method", + "operationId": "post-notification-template-method", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -5354,6 +5404,90 @@ const docTemplate = `{ } } }, + "/users/{user}/notifications/preferences": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get user notification preferences", + "operationId": "get-user-notification-preferences", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationPreference" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Update user notification preferences", + "operationId": "update-user-notification-preferences", + "parameters": [ + { + "description": "Preferences", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationPreference" + } + } + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ @@ -10199,6 +10333,52 @@ const docTemplate = `{ } } }, + "codersdk.NotificationPreference": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { @@ -12514,6 +12694,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserNotificationPreferences": { + "type": "object", + "properties": { + "template_disabled_map": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + }, "codersdk.UpdateUserPasswordRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 219dc7a3b2b80..a73cdc36a55fe 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1352,7 +1352,7 @@ } ], "produces": ["application/json"], - "tags": ["General"], + "tags": ["Notifications"], "summary": "Get notifications settings", "operationId": "get-notifications-settings", "responses": { @@ -1372,7 +1372,7 @@ ], "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["General"], + "tags": ["Notifications"], "summary": "Update notifications settings", "operationId": "update-notifications-settings", "parameters": [ @@ -1399,6 +1399,48 @@ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, + "/notifications/templates/{notification_template}/method": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update notification template dispatch method", + "operationId": "post-notification-template-method", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -4726,6 +4768,80 @@ } } }, + "/users/{user}/notifications/preferences": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get user notification preferences", + "operationId": "get-user-notification-preferences", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationPreference" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Update user notification preferences", + "operationId": "update-user-notification-preferences", + "parameters": [ + { + "description": "Preferences", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationPreference" + } + } + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ @@ -9140,6 +9256,52 @@ } } }, + "codersdk.NotificationPreference": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { @@ -11363,6 +11525,17 @@ } } }, + "codersdk.UpdateUserNotificationPreferences": { + "type": "object", + "properties": { + "template_disabled_map": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + }, "codersdk.UpdateUserPasswordRequest": { "type": "object", "required": ["password"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f8a59ad6efc6..1fb662321cecc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1050,6 +1050,12 @@ func New(options *Options) *API { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) + r.Route("/notifications", func(r chi.Router) { + r.Route("/preferences", func(r chi.Router) { + r.Get("/", api.userNotificationPreferences) + r.Put("/", api.putUserNotificationPreferences) + }) + }) }) }) }) @@ -1243,9 +1249,15 @@ func New(options *Options) *API { }) }) r.Route("/notifications", func(r chi.Router) { - r.Use(apiKeyMiddleware) + r.Use( + apiKeyMiddleware, + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentNotifications), + ) r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) + r.Route("/templates", func(r chi.Router) { + r.Get("/system", api.systemNotificationTemplates) + }) }) }) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 876c0d797f64a..ba2f07caaf8e2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -16,6 +16,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" @@ -2555,6 +2556,10 @@ func (s *MethodTestSuite) TestSystemFunctions() { AgentID: uuid.New(), }).Asserts(tpl, policy.ActionCreate) })) +} + +func (s *MethodTestSuite) TestNotifications() { + // System functions s.Run("AcquireNotificationMessages", s.Subtest(func(db database.Store, check *expects) { // TODO: update this test once we have a specific role for notifications check.Args(database.AcquireNotificationMessagesParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate) @@ -2590,6 +2595,40 @@ func (s *MethodTestSuite) TestSystemFunctions() { Limit: 10, }).Asserts(rbac.ResourceSystem, policy.ActionRead) })) + + // Notification templates + s.Run("GetNotificationTemplateById", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID).Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead). + Errors(dbmem.ErrUnimplemented) + })) + s.Run("GetNotificationTemplatesByKind", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.NotificationTemplateKindSystem). + Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead). + Errors(dbmem.ErrUnimplemented) + })) + s.Run("UpdateNotificationTemplateMethodById", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpdateNotificationTemplateMethodByIdParams{ + Method: database.NullNotificationMethod{NotificationMethod: database.NotificationMethodWebhook, Valid: true}, + ID: notifications.TemplateWorkspaceDormant, + }).Asserts(rbac.ResourceNotificationTemplate, policy.ActionUpdate). + Errors(dbmem.ErrUnimplemented) + })) + + // Notification preferences + s.Run("GetUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID). + Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionRead) + })) + s.Run("UpdateUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(database.UpdateUserNotificationPreferencesParams{ + UserID: user.ID, + NotificationTemplateIds: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated, notifications.TemplateWorkspaceDeleted}, + Disableds: []bool{true, false}, + }).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestOAuth2ProviderApps() { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 775000ac6ba05..c582b4a954875 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -290,6 +290,10 @@ func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object { return a.OAuth2ProviderApp.RBACObject() } +func (n NotificationPreference) RBACObject() rbac.Object { + return rbac.ResourceNotificationPreference.WithOwner(n.UserID.String()) +} + type WorkspaceAgentConnectionStatus struct { Status WorkspaceAgentStatus `json:"status"` FirstConnectedAt *time.Time `json:"first_connected_at"` diff --git a/coderd/httpmw/notificationtemplateparam.go b/coderd/httpmw/notificationtemplateparam.go new file mode 100644 index 0000000000000..ae86d48d033e8 --- /dev/null +++ b/coderd/httpmw/notificationtemplateparam.go @@ -0,0 +1,49 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type notificationTemplateParamContextKey struct{} + +// NotificationTemplateParam returns the template from the ExtractNotificationTemplateParam handler. +func NotificationTemplateParam(r *http.Request) database.NotificationTemplate { + template, ok := r.Context().Value(notificationTemplateParamContextKey{}).(database.NotificationTemplate) + if !ok { + panic("developer error: notification template param middleware not provided") + } + return template +} + +// ExtractNotificationTemplateParam grabs a notification template from the "notification_template" URL parameter. +func ExtractNotificationTemplateParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + notifTemplateID, parsed := ParseUUIDParam(rw, r, "notification_template") + if !parsed { + return + } + nt, err := db.GetNotificationTemplateById(r.Context(), notifTemplateID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching notification template.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, notificationTemplateParamContextKey{}, nt) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/notifications.go b/coderd/notifications.go index f6bcbe0c7183d..3931606a5612d 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -7,11 +7,13 @@ import ( "github.com/google/uuid" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -19,7 +21,7 @@ import ( // @ID get-notifications-settings // @Security CoderSessionToken // @Produce json -// @Tags General +// @Tags Notifications // @Success 200 {object} codersdk.NotificationsSettings // @Router /notifications/settings [get] func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { @@ -51,7 +53,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Accept json // @Produce json -// @Tags General +// @Tags Notifications // @Param request body codersdk.NotificationsSettings true "Notifications settings request" // @Success 200 {object} codersdk.NotificationsSettings // @Success 304 @@ -59,13 +61,6 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Insufficient permissions to update notifications settings.", - }) - return - } - var settings codersdk.NotificationsSettings if !httpapi.Read(ctx, rw, r, &settings) { return @@ -80,9 +75,9 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request return } - currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + currentSettingsJSON, err := api.Database.GetNotificationsSettings(ctx) if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to fetch current notifications settings.", Detail: err.Error(), }) @@ -91,7 +86,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { // See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1 - httpapi.Write(r.Context(), rw, http.StatusNotModified, nil) + httpapi.Write(ctx, rw, http.StatusNotModified, nil) return } @@ -110,13 +105,171 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request } err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON)) + if err != nil { + if rbac.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + } else { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications settings.", + Detail: err.Error(), + }) + } + + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} + +// @Summary Get system notification templates +// @ID get-system-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Router /notifications/templates/system [get] +func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update notifications settings.", + Message: "Failed to retrieve system notifications templates.", Detail: err.Error(), }) return } - httpapi.Write(r.Context(), rw, http.StatusOK, settings) + out := convertNotificationTemplates(templates) + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +// @Summary Get user notification preferences +// @ID get-user-notification-preferences +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Param user path string true "User ID, name, or me" +// @Success 200 {array} codersdk.NotificationPreference +// @Router /users/{user}/notifications/preferences [get] +func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID)) + ) + + prefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID) + if err != nil { + logger.Error(ctx, "failed to retrieve") + + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to retrieve user notification preferences.", + Detail: err.Error(), + }) + return + } + + out := convertNotificationPreferences(prefs) + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +// @Summary Update user notification preferences +// @ID update-user-notification-preferences +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Notifications +// @Param request body codersdk.UpdateUserNotificationPreferences true "Preferences" +// @Param user path string true "User ID, name, or me" +// @Success 200 {array} codersdk.NotificationPreference +// @Router /users/{user}/notifications/preferences [put] +func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID)) + ) + + var prefs codersdk.UpdateUserNotificationPreferences + if !httpapi.Read(ctx, rw, r, &prefs) { + return + } + + input := database.UpdateUserNotificationPreferencesParams{ + UserID: user.ID, + NotificationTemplateIds: make([]uuid.UUID, 0, len(prefs.TemplateDisabledMap)), + Disableds: make([]bool, 0, len(prefs.TemplateDisabledMap)), + } + for tmplID, disabled := range prefs.TemplateDisabledMap { + id, err := uuid.Parse(tmplID) + if err != nil { + logger.Warn(ctx, "failed to parse notification template UUID", slog.F("input", tmplID)) + + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Unable to parse notification template UUID.", + Detail: err.Error(), + }) + return + } + + input.NotificationTemplateIds = append(input.NotificationTemplateIds, id) + input.Disableds = append(input.Disableds, disabled) + } + + updated, err := api.Database.UpdateUserNotificationPreferences(ctx, input) + if err != nil { + logger.Error(ctx, "failed to update", slog.Error(err)) + + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update user notifications preferences.", + Detail: err.Error(), + }) + return + } + + logger.Info(ctx, "updated", slog.F("count", updated)) + + userPrefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID) + if err != nil { + logger.Error(ctx, "failed to retrieve", slog.Error(err)) + + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to retrieve user notifications preferences.", + Detail: err.Error(), + }) + return + } + + out := convertNotificationPreferences(userPrefs) + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { + for _, tmpl := range in { + out = append(out, codersdk.NotificationTemplate{ + ID: tmpl.ID, + Name: tmpl.Name, + TitleTemplate: tmpl.TitleTemplate, + BodyTemplate: tmpl.BodyTemplate, + Actions: string(tmpl.Actions), + Group: tmpl.Group.String, + Method: string(tmpl.Method.NotificationMethod), + Kind: string(tmpl.Kind), + }) + } + + return out +} + +func convertNotificationPreferences(in []database.NotificationPreference) (out []codersdk.NotificationPreference) { + for _, pref := range in { + out = append(out, codersdk.NotificationPreference{ + NotificationTemplateID: pref.NotificationTemplateID, + Disabled: pref.Disabled, + UpdatedAt: pref.UpdatedAt, + }) + } + + return out } diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index 32822dd6ab9d7..872c1cad20a54 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -3,6 +3,7 @@ package notifications import ( "context" "encoding/json" + "strings" "text/template" "github.com/google/uuid" @@ -16,6 +17,8 @@ import ( "github.com/coder/coder/v2/codersdk" ) +var ErrCannotEnqueueDisabledNotification = xerrors.New("user has disabled this notification") + type StoreEnqueuer struct { store Store log slog.Logger @@ -69,6 +72,10 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI CreatedBy: createdBy, }) if err != nil { + if strings.Contains(err.Error(), ErrCannotEnqueueDisabledNotification.Error()) { + return nil, ErrCannotEnqueueDisabledNotification + } + s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) return nil, xerrors.Errorf("enqueue notification: %w", err) } diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 5f5d30974a302..2bded5630d4d3 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -249,15 +249,24 @@ func (m *Manager) syncUpdates(ctx context.Context) { for i := 0; i < nFailure; i++ { res := <-m.failure - status := database.NotificationMessageStatusPermanentFailure - if res.retryable { + var ( + reason string + status database.NotificationMessageStatus + ) + + switch { + case res.retryable: status = database.NotificationMessageStatusTemporaryFailure + case res.inhibited: + status = database.NotificationMessageStatusInhibited + reason = "disabled by user" + default: + status = database.NotificationMessageStatusPermanentFailure } failureParams.IDs = append(failureParams.IDs, res.msg) failureParams.FailedAts = append(failureParams.FailedAts, res.ts) failureParams.Statuses = append(failureParams.Statuses, status) - var reason string if res.err != nil { reason = res.err.Error() } @@ -367,4 +376,5 @@ type dispatchResult struct { ts time.Time err error retryable bool + inhibited bool } diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 37fe4a2ce5ce3..ef6642a0a8a60 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -705,6 +705,94 @@ func TestNotifcationTemplatesBody(t *testing.T) { } } +// TestDisabledBeforeEnqueue ensures that notifications cannot be enqueued once a user has disabled that notification template +func TestDisabledBeforeEnqueue(t *testing.T) { + t.Parallel() + + // SETUP + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it is testing business-logic implemented in the database") + } + + ctx, logger, db := setup(t) + + // GIVEN: an enqueuer & a sample user + cfg := defaultNotificationsConfig(database.NotificationMethodSmtp) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + user := createSampleUser(t, db) + + // WHEN: the user has a preference set to not receive the "workspace deleted" notification + templateId := notifications.TemplateWorkspaceDeleted + n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{ + UserID: user.ID, + NotificationTemplateIds: []uuid.UUID{templateId}, + Disableds: []bool{true}, + }) + require.NoError(t, err, "failed to set preferences") + require.EqualValues(t, 1, n, "unexpected number of affected rows") + + // THEN: enqueuing the "workspace deleted" notification should fail with an error + _, err = enq.Enqueue(ctx, user.ID, templateId, map[string]string{}, "test") + require.ErrorIs(t, err, notifications.ErrCannotEnqueueDisabledNotification, "enqueueing did not fail with expected error") +} + +// TestDisabledAfterEnqueue ensures that notifications enqueued before a notification template was disabled will not be +// sent, and will instead be marked as "inhibited". +func TestDisabledAfterEnqueue(t *testing.T) { + t.Parallel() + + // SETUP + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it is testing business-logic implemented in the database") + } + + ctx, logger, db := setup(t) + + method := database.NotificationMethodSmtp + cfg := defaultNotificationsConfig(method) + + mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager")) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + user := createSampleUser(t, db) + + // GIVEN: a notification is enqueued which has not (yet) been disabled + templateId := notifications.TemplateWorkspaceDeleted + msgId, err := enq.Enqueue(ctx, user.ID, templateId, map[string]string{}, "test") + require.NoError(t, err) + + // Disable the notification template. + n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{ + UserID: user.ID, + NotificationTemplateIds: []uuid.UUID{templateId}, + Disableds: []bool{true}, + }) + require.NoError(t, err, "failed to set preferences") + require.EqualValues(t, 1, n, "unexpected number of affected rows") + + // WHEN: running the manager to trigger dequeueing of (now-disabled) messages + mgr.Run(ctx) + + // THEN: the message should not be sent, and must be set to "inhibited" + require.EventuallyWithT(t, func(ct *assert.CollectT) { + m, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusInhibited, + Limit: 10, + }) + assert.NoError(ct, err) + if assert.Equal(ct, len(m), 1) { + assert.Equal(ct, m[0].ID.String(), msgId.String()) + assert.Contains(ct, m[0].StatusReason.String, "disabled by user") + } + }, testutil.WaitLong, testutil.IntervalFast, "did not find the expected inhibited message") +} + type fakeHandler struct { mu sync.RWMutex succeeded, failed []string diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index c39de6168db81..549d433840f9b 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -144,6 +144,13 @@ func (n *notifier) process(ctx context.Context, success chan<- dispatchResult, f var eg errgroup.Group for _, msg := range msgs { + + // If a notification template has been disabled by the user after a notification was enqueued, mark it as inhibited + if msg.Disabled { + failure <- n.newInhibitedDispatch(msg) + continue + } + // A message failing to be prepared correctly should not affect other messages. deliverFn, err := n.prepare(ctx, msg) if err != nil { @@ -312,6 +319,16 @@ func (n *notifier) newFailedDispatch(msg database.AcquireNotificationMessagesRow } } +func (n *notifier) newInhibitedDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult { + return dispatchResult{ + notifier: n.id, + msg: msg.ID, + ts: time.Now(), + retryable: false, + inhibited: true, + } +} + // stop stops the notifier from processing any new notifications. // This is a graceful stop, so any in-flight notifications will be completed before the notifier stops. // Once a notifier has stopped, it cannot be restarted. diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 7690154a0db80..77d63d00a32c9 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -7,17 +7,28 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) +func createOpts(t *testing.T) *coderdtest.Options { + t.Helper() + + dt := coderdtest.DeploymentValues(t) + dt.Experiments = []string{string(codersdk.ExperimentNotifications)} + return &coderdtest.Options{ + DeploymentValues: dt, + } +} + func TestUpdateNotificationsSettings(t *testing.T) { t.Parallel() t.Run("Permissions denied", func(t *testing.T) { t.Parallel() - api := coderdtest.New(t, nil) + api := coderdtest.New(t, createOpts(t)) firstUser := coderdtest.CreateFirstUser(t, api) anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) @@ -41,7 +52,7 @@ func TestUpdateNotificationsSettings(t *testing.T) { t.Run("Settings modified", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := coderdtest.New(t, createOpts(t)) _ = coderdtest.CreateFirstUser(t, client) // given @@ -65,7 +76,7 @@ func TestUpdateNotificationsSettings(t *testing.T) { t.Parallel() // Empty state: notifications Settings are undefined now (default). - client := coderdtest.New(t, nil) + client := coderdtest.New(t, createOpts(t)) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitShort) @@ -93,3 +104,164 @@ func TestUpdateNotificationsSettings(t *testing.T) { require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) }) } + +func TestNotificationPreferences(t *testing.T) { + t.Parallel() + + t.Run("Initial state", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member in its initial state. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: calling the API. + prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + + // Then: no preferences will be returned. + require.Len(t, prefs, 0) + }) + + t.Run("Insufficient permissions", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: 2 members. + _, member1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + member2Client, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to retrieve the preferences of another member. + _, err := member2Client.GetUserNotificationPreferences(ctx, member1.ID) + + // Then: the API should reject the request. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + // NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden. + // This is not ideal, and we should probably change this behavior. + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + }) + + t.Run("Admin may read any users' preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member. + _, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to retrieve the preferences of another member as an admin. + prefs, err := api.GetUserNotificationPreferences(ctx, member.ID) + + // Then: the API should not reject the request. + require.NoError(t, err) + require.Len(t, prefs, 0) + }) + + t.Run("Admin may update any users' preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to modify and subsequently retrieve the preferences of another member as an admin. + prefs, err := api.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceMarkedForDeletion.String(): true, + }, + }) + + // Then: the request should succeed and the user should be able to query their own preferences to see the same result. + require.NoError(t, err) + require.Len(t, prefs, 1) + + memberPrefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + require.Len(t, memberPrefs, 1) + require.Equal(t, prefs[0].NotificationTemplateID, memberPrefs[0].NotificationTemplateID) + require.Equal(t, prefs[0].Disabled, memberPrefs[0].Disabled) + }) + + t.Run("Add preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member with no preferences. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + require.Len(t, prefs, 0) + + // When: attempting to add new preferences. + template := notifications.TemplateWorkspaceDeleted + prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + template.String(): true, + }, + }) + + // Then: the returning preferences should be set as expected. + require.NoError(t, err) + require.Len(t, prefs, 1) + require.Equal(t, prefs[0].NotificationTemplateID, template) + require.True(t, prefs[0].Disabled) + }) + + t.Run("Modify preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member with preferences. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + prefs, err := memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceDeleted.String(): true, + notifications.TemplateWorkspaceDormant.String(): true, + }, + }) + require.NoError(t, err) + require.Len(t, prefs, 2) + + // When: attempting to modify their preferences. + prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceDeleted.String(): true, + notifications.TemplateWorkspaceDormant.String(): false, // <--- this one was changed + }, + }) + require.NoError(t, err) + require.Len(t, prefs, 2) + + // Then: the modified preferences should be set as expected. + var found bool + for _, p := range prefs { + switch p.NotificationTemplateID { + case notifications.TemplateWorkspaceDormant: + found = true + require.False(t, p.Disabled) + case notifications.TemplateWorkspaceDeleted: + require.True(t, p.Disabled) + } + } + require.True(t, found, "dormant notification preference was not found") + }) +} diff --git a/codersdk/audit.go b/codersdk/audit.go index be2959127fd4c..7d83c8e238ce0 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -33,7 +33,7 @@ const ( ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" ResourceTypeOrganizationMember = "organization_member" - ResourceTypeNotificationTemplate = "notification_template" + ResourceTypeNotificationTemplate = "notification_template" ) func (r ResourceType) FriendlyString() string { diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 58829eed57891..32aad524f79f4 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -3,13 +3,38 @@ package codersdk import ( "context" "encoding/json" + "fmt" + "io" "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" ) type NotificationsSettings struct { NotifierPaused bool `json:"notifier_paused"` } +type NotificationTemplate struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + TitleTemplate string `json:"title_template"` + BodyTemplate string `json:"body_template"` + Actions string `json:"actions" format:""` + Group string `json:"group"` + Method string `json:"method"` + Kind string `json:"kind"` +} + +type NotificationPreference struct { + NotificationTemplateID uuid.UUID `json:"id" format:"uuid"` + Disabled bool `json:"disabled"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// GetNotificationsSettings retrieves the notifications settings, which currently just describes whether all +// notifications are paused from sending. func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil) if err != nil { @@ -23,6 +48,8 @@ func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSet return settings, json.NewDecoder(res.Body).Decode(&settings) } +// PutNotificationsSettings modifies the notifications settings, which currently just controls whether all +// notifications are paused from sending. func (c *Client) PutNotificationsSettings(ctx context.Context, settings NotificationsSettings) error { res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/settings", settings) if err != nil { @@ -38,3 +65,107 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica } return nil } + +// UpdateNotificationTemplateMethod modifies a notification template to use a specific notification method, overriding +// the method set in the deployment configuration. +func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateId uuid.UUID, method string) error { + res, err := c.Request(ctx, http.MethodPut, + fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateId), + UpdateNotificationTemplateMethod{Method: method}, + ) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotModified { + return nil + } + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + +// GetSystemNotificationTemplates retrieves all notification templates pertaining to internal system events. +func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]NotificationTemplate, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/templates/system", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var templates []NotificationTemplate + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &templates); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return templates, nil +} + +// GetUserNotificationPreferences TODO +func (c *Client) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var prefs []NotificationPreference + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &prefs); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return prefs, nil +} + +// UpdateUserNotificationPreferences TODO +func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, userID uuid.UUID, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var prefs []NotificationPreference + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &prefs); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return prefs, nil +} + +type UpdateNotificationTemplateMethod struct { + Method string `json:"method,omitempty" example:"webhook"` +} + +type UpdateUserNotificationPreferences struct { + TemplateDisabledMap map[string]bool `json:"template_disabled_map"` +} diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index dec875eebaac3..a4de96701c817 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -537,6 +537,26 @@ curl -X DELETE http://coder-server:8080/api/v2/licenses/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update notification template dispatch method + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/templates/{notification_template}/method` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get OAuth2 applications. ### Code samples diff --git a/docs/api/general.md b/docs/api/general.md index e913a4c804cd6..52cfd25f4c46c 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -667,84 +667,6 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get notifications settings - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/notifications/settings \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /notifications/settings` - -### Example responses - -> 200 Response - -```json -{ - "notifier_paused": true -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Update notifications settings - -### Code samples - -```shell -# Example request using curl -curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`PUT /notifications/settings` - -> Body parameter - -```json -{ - "notifier_paused": true -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ | -| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request | - -### Example responses - -> 200 Response - -```json -{ - "notifier_paused": true -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | -| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Update check ### Code samples diff --git a/docs/api/notifications.md b/docs/api/notifications.md new file mode 100644 index 0000000000000..25ef607acca8f --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,252 @@ +# Notifications + +## Get notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/settings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/settings` + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/settings` + +> Body parameter + +```json +{ + "notifier_paused": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ | +| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request | + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | +| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get system notification templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/templates/system` + +### Example responses + +> 200 Response + +```json +[ + { + "actions": "string", + "body_template": "string", + "group": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "kind": "string", + "method": "string", + "name": "string", + "title_template": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | + +