From 492c4fc22bd1ac85d4a20dab8c6b337635223e5c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 21:04:15 +0200 Subject: [PATCH 01/13] CI Signed-off-by: Danny Kopping --- codersdk/audit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From bfb2981b5982b30c4ed538428f619b327b94cc29 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 21:17:34 +0200 Subject: [PATCH 02/13] dbauthz tests Signed-off-by: Danny Kopping --- coderd/database/dbauthz/dbauthz_test.go | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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() { From 5b6f3e34f8e6a9d4905f1c98753053bd09771cdf Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 25 Jul 2024 22:00:50 +0200 Subject: [PATCH 03/13] Update template method API Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 22 +++++ coderd/apidoc/swagger.json | 18 +++++ coderd/httpmw/notificationtemplateparam.go | 49 +++++++++++ coderd/notifications/methods.go | 10 +++ codersdk/notifications.go | 4 + docs/api/enterprise.md | 20 +++++ enterprise/coderd/coderd.go | 10 ++- enterprise/coderd/notifications.go | 94 ++++++++++++++++++++++ site/src/api/typesGenerated.ts | 5 ++ 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 coderd/httpmw/notificationtemplateparam.go create mode 100644 coderd/notifications/methods.go create mode 100644 enterprise/coderd/notifications.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1ce9c6ffda204..035a31848e1ff 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1612,6 +1612,28 @@ const docTemplate = `{ } } }, + "/notifications/templates/{notification_template}/method": { + "post": { + "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": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 219dc7a3b2b80..2b982f3512c18 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1399,6 +1399,24 @@ } } }, + "/notifications/templates/{notification_template}/method": { + "post": { + "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": [ 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/methods.go b/coderd/notifications/methods.go new file mode 100644 index 0000000000000..3d49b72426ad5 --- /dev/null +++ b/coderd/notifications/methods.go @@ -0,0 +1,10 @@ +package notifications + +import "github.com/coder/coder/v2/coderd/database" + +func ValidNotificationMethods() []string { + return []string{ + string(database.NotificationMethodSmtp), + string(database.NotificationMethodWebhook), + } +} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 58829eed57891..3da20785db254 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -38,3 +38,7 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica } return nil } + +type UpdateNotificationTemplateMethod struct { + Method string `json:"method,omitempty" example:"webhook"` +} diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index dec875eebaac3..9e4c7fbd9ce2f 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 POST http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /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/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index e9e8d7d196af0..bff3bac138491 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -368,7 +368,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) - r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( api.autostopRequirementEnabledMW, @@ -388,6 +387,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Post("/jfrog/xray-scan", api.postJFrogXrayScan) r.Get("/jfrog/xray-scan", api.jFrogXrayScan) }) + r.Route("/notifications/templates", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/{notification_template}", func(r chi.Router) { + r.Use( + httpmw.ExtractNotificationTemplateParam(options.Database), + ) + r.Post("/method", api.updateNotificationTemplateMethod) + }) + }) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go new file mode 100644 index 0000000000000..56390c1e505f8 --- /dev/null +++ b/enterprise/coderd/notifications.go @@ -0,0 +1,94 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "golang.org/x/xerrors" + + "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/notifications" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Update notification template dispatch method +// @ID post-notification-template-method +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Success 200 +// @Router /notifications/templates/{notification_template}/method [post] +func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) { + // TODO: authorization (admin/template admin) + // auth := httpmw.UserAuthorization(r) + + var ( + ctx = r.Context() + template = httpmw.NotificationTemplateParam(r) + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.NotificationTemplate](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + + var req codersdk.UpdateNotificationTemplateMethod + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var nm database.NullNotificationMethod + if err := nm.Scan(req.Method); err != nil || !nm.Valid || string(nm.NotificationMethod) == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update notification template method", + Validations: []codersdk.ValidationError{ + { + Field: "method", + Detail: fmt.Sprintf("%q is not a valid method; %s are the available options", + req.Method, strings.Join(notifications.ValidNotificationMethods(), ", "), + ), + }, + }, + }) + return + } + + if template.Method == nm { + httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ + Message: "Notification template method unchanged.", + }) + return + } + + defer commitAudit() + aReq.Old = template + + err := api.Database.InTx(func(tx database.Store) error { + var err error + template, err = api.Database.UpdateNotificationTemplateMethodById(r.Context(), database.UpdateNotificationTemplateMethodByIdParams{ + ID: template.ID, + Method: nm, + }) + if err != nil { + return xerrors.Errorf("failed to update notification template ID: %w", err) + } + + return err + }, nil) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + aReq.New = template + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Successfully updated notification template method.", + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8ccfefd30b921..87569738686c6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1437,6 +1437,11 @@ export interface UpdateCheckResponse { readonly url: string; } +// From codersdk/notifications.go +export interface UpdateNotificationTemplateMethod { + readonly method?: string; +} + // From codersdk/organizations.go export interface UpdateOrganizationRequest { readonly name?: string; From a145af056c78f273e7ff9a9b8f1a392f8f14e9ea Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 08:56:09 +0200 Subject: [PATCH 04/13] Template method update tests Signed-off-by: Danny Kopping --- coderd/coderd.go | 6 +- coderd/notifications.go | 41 +++++ coderd/notifications/methods.go | 10 -- codersdk/notifications.go | 59 ++++++ enterprise/coderd/coderd.go | 14 +- enterprise/coderd/notifications.go | 15 +- enterprise/coderd/notifications_test.go | 228 ++++++++++++++++++++++++ 7 files changed, 347 insertions(+), 26 deletions(-) delete mode 100644 coderd/notifications/methods.go create mode 100644 enterprise/coderd/notifications_test.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f8a59ad6efc6..4906554b014ce 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1243,9 +1243,13 @@ 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.Get("/templates/system", api.getSystemNotificationTemplates) }) }) diff --git a/coderd/notifications.go b/coderd/notifications.go index f6bcbe0c7183d..9b419a06e65ef 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -120,3 +120,44 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request httpapi.Write(r.Context(), rw, http.StatusOK, settings) } + +func (api *API) getSystemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + templates, err := api.Database.GetSystemNotificationTemplates(ctx) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to retrieve system notifications templates.", + Detail: err.Error(), + }) + return + } + + out, err := convertNotificationTemplates(templates) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to convert retrieved notifications templates to marshalable form.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate, err error) { + for _, tmpl := range in { + out = append(out, codersdk.NotificationTemplate{ + ID: tmpl.ID, + Name: tmpl.Name, + TitleTemplate: tmpl.TitleTemplate, + BodyTemplate: tmpl.BodyTemplate, + Actions: tmpl.Actions, + Group: tmpl.Group.String, + Method: string(tmpl.Method.NotificationMethod), + IsSystem: tmpl.IsSystem, + }) + } + + return out, nil +} diff --git a/coderd/notifications/methods.go b/coderd/notifications/methods.go deleted file mode 100644 index 3d49b72426ad5..0000000000000 --- a/coderd/notifications/methods.go +++ /dev/null @@ -1,10 +0,0 @@ -package notifications - -import "github.com/coder/coder/v2/coderd/database" - -func ValidNotificationMethods() []string { - return []string{ - string(database.NotificationMethodSmtp), - string(database.NotificationMethodWebhook), - } -} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 3da20785db254..cffd53956a85c 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -3,13 +3,29 @@ package codersdk import ( "context" "encoding/json" + "fmt" + "io" "net/http" + + "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 []byte `json:"actions"` + Group string `json:"group"` + Method string `json:"method"` + IsSystem bool `json:"is_system"` +} + func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil) if err != nil { @@ -39,6 +55,49 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica return nil } +func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateId uuid.UUID, method string) error { + res, err := c.Request(ctx, http.MethodPost, + 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 +} + +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 +} + type UpdateNotificationTemplateMethod struct { Method string `json:"method,omitempty" example:"webhook"` } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index bff3bac138491..e079ea18ff992 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -387,15 +387,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Post("/jfrog/xray-scan", api.postJFrogXrayScan) r.Get("/jfrog/xray-scan", api.jFrogXrayScan) }) - r.Route("/notifications/templates", func(r chi.Router) { - r.Use(apiKeyMiddleware) - r.Route("/{notification_template}", func(r chi.Router) { - r.Use( - httpmw.ExtractNotificationTemplateParam(options.Database), - ) - r.Post("/method", api.updateNotificationTemplateMethod) - }) - }) + r.With( + apiKeyMiddleware, + httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications), + httpmw.ExtractNotificationTemplateParam(options.Database), + ).Post("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go index 56390c1e505f8..038dc684cef27 100644 --- a/enterprise/coderd/notifications.go +++ b/enterprise/coderd/notifications.go @@ -11,7 +11,6 @@ import ( "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/notifications" "github.com/coder/coder/v2/codersdk" ) @@ -23,9 +22,7 @@ import ( // @Success 200 // @Router /notifications/templates/{notification_template}/method [post] func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) { - // TODO: authorization (admin/template admin) - // auth := httpmw.UserAuthorization(r) - + // TODO: authorization (restrict to admin/template admin?) var ( ctx = r.Context() template = httpmw.NotificationTemplateParam(r) @@ -44,14 +41,20 @@ func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http } var nm database.NullNotificationMethod - if err := nm.Scan(req.Method); err != nil || !nm.Valid || string(nm.NotificationMethod) == "" { + if err := nm.Scan(req.Method); err != nil || !nm.Valid || !nm.NotificationMethod.Valid() { + vals := database.AllNotificationMethodValues() + acceptable := make([]string, len(vals)) + for i, v := range vals { + acceptable[i] = string(v) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update notification template method", Validations: []codersdk.ValidationError{ { Field: "method", Detail: fmt.Sprintf("%q is not a valid method; %s are the available options", - req.Method, strings.Join(notifications.ValidNotificationMethods(), ", "), + req.Method, strings.Join(acceptable, ", "), ), }, }, diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go new file mode 100644 index 0000000000000..9b4c5eccb4a27 --- /dev/null +++ b/enterprise/coderd/notifications_test.go @@ -0,0 +1,228 @@ +package coderd_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/testutil" +) + +func createOpts(t *testing.T) *coderdenttest.Options { + t.Helper() + + db, ps := dbtestutil.NewDB(t) + + dt := coderdtest.DeploymentValues(t) + dt.Experiments = []string{string(codersdk.ExperimentNotifications)} + return &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dt, + + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: ps, + }, + } +} + +func TestUpdateNotificationTemplateMethod(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") + } + + t.Run("Happy path", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + api, _ := coderdenttest.New(t, createOpts(t)) + + var ( + method = string(database.NotificationMethodSmtp) + templateID = notifications.TemplateWorkspaceDeleted + ) + + // Given: a template whose method is initially empty (i.e. deferring to the global method value). + template, err := getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Empty(t, template.Method) + + // When: calling the API to update the method. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed") + + // Then: the method should be set. + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + }) + + t.Run("Insufficient permissions", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Given: the first user which has an "owner" role, and another user which does not. + api, firstUser := coderdenttest.New(t, createOpts(t)) + anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: calling the API as an unprivileged user. + err := anotherClient.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, string(database.NotificationMethodWebhook)) + + // Then: the request is denied because of insufficient permissions. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) + require.Equal(t, "Resource not found or you do not have access to this resource", sdkError.Response.Message) + }) + + t.Run("Invalid notification method", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Given: the first user which has an "owner" role + api, _ := coderdenttest.New(t, createOpts(t)) + + // When: calling the API with an invalid method. + const method = "nope" + err := api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method) + + // Then: the request is invalid because of the unacceptable method. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, "Invalid request to update notification template method", sdkError.Response.Message) + require.Len(t, sdkError.Response.Validations, 1) + require.Equal(t, "method", sdkError.Response.Validations[0].Field) + require.Equal(t, fmt.Sprintf("%q is not a valid method; smtp, webhook are the available options", method), sdkError.Response.Validations[0].Detail) + }) + + t.Run("Not modified", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + api, _ := coderdenttest.New(t, createOpts(t)) + + var ( + method = string(database.NotificationMethodSmtp) + templateID = notifications.TemplateWorkspaceDeleted + ) + + template, err := getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + + // Given: a template whose method is initially empty (i.e. deferring to the global method value). + require.Empty(t, template.Method) + + // When: calling the API to update the method, it should set it. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed") + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + + // Then: when calling the API again with the same method, the method will remain unchanged. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "second request to set the method failed") + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + }) + + // t.Run("Settings modified", func(t *testing.T) { + // t.Parallel() + // + // client := coderdtest.New(t, nil) + // _ = coderdtest.CreateFirstUser(t, client) + // + // // given + // expected := codersdk.NotificationsSettings{ + // NotifierPaused: true, + // } + // + // ctx := testutil.Context(t, testutil.WaitShort) + // + // // when + // err := client.PutNotificationsSettings(ctx, expected) + // require.NoError(t, err) + // + // // then + // actual, err := client.GetNotificationsSettings(ctx) + // require.NoError(t, err) + // require.Equal(t, expected, actual) + // }) + // + // t.Run("Settings not modified", func(t *testing.T) { + // t.Parallel() + // + // // Empty state: notifications Settings are undefined now (default). + // client := coderdtest.New(t, nil) + // _ = coderdtest.CreateFirstUser(t, client) + // ctx := testutil.Context(t, testutil.WaitShort) + // + // // Change the state: pause notifications + // err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + // NotifierPaused: true, + // }) + // require.NoError(t, err) + // + // // Verify the state: notifications are paused. + // actual, err := client.GetNotificationsSettings(ctx) + // require.NoError(t, err) + // require.True(t, actual.NotifierPaused) + // + // // Change the stage again: notifications are paused. + // expected := actual + // err = client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + // NotifierPaused: true, + // }) + // require.NoError(t, err) + // + // // Verify the state: notifications are still paused, and there is no error returned. + // actual, err = client.GetNotificationsSettings(ctx) + // require.NoError(t, err) + // require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) + // }) +} + +func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) { + t.Helper() + + var template *codersdk.NotificationTemplate + templates, err := api.GetSystemNotificationTemplates(ctx) + if err != nil { + return nil, err + } + + for _, tmpl := range templates { + if tmpl.ID == id { + template = &tmpl + } + } + + if template == nil { + return nil, xerrors.Errorf("template not found: %q", id.String()) + } + + return template, nil +} \ No newline at end of file From 922a4645deb81337922163d62fa634e4fdd684d8 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 09:48:53 +0200 Subject: [PATCH 05/13] make gen Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 62 ++++++++++++++- coderd/apidoc/swagger.json | 58 +++++++++++++- coderd/notifications.go | 27 ++++--- codersdk/notifications.go | 9 ++- docs/api/general.md | 78 ------------------- docs/api/notifications.md | 135 +++++++++++++++++++++++++++++++++ docs/api/schemas.md | 28 +++++++ docs/manifest.json | 4 + enterprise/coderd/coderd.go | 4 + site/src/api/typesGenerated.ts | 12 +++ 10 files changed, 320 insertions(+), 97 deletions(-) create mode 100644 docs/api/notifications.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 035a31848e1ff..eee131fe3a021 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,34 @@ const docTemplate = `{ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification templates pertaining to system events", + "operationId": "system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, "/notifications/templates/{notification_template}/method": { "post": { "security": [ @@ -10221,6 +10249,36 @@ const docTemplate = `{ } } }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_system": { + "type": "boolean" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2b982f3512c18..9c5071c5fedba 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,30 @@ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get notification templates pertaining to system events", + "operationId": "system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, "/notifications/templates/{notification_template}/method": { "post": { "security": [ @@ -9158,6 +9182,36 @@ } } }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_system": { + "type": "boolean" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { diff --git a/coderd/notifications.go b/coderd/notifications.go index 9b419a06e65ef..04b801266f7b7 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -19,7 +19,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 +51,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 @@ -121,6 +121,13 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request httpapi.Write(r.Context(), rw, http.StatusOK, settings) } +// @Summary Get notification templates pertaining to system events +// @ID system-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Router /notifications/templates/system [get] func (api *API) getSystemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -133,31 +140,23 @@ func (api *API) getSystemNotificationTemplates(rw http.ResponseWriter, r *http.R return } - out, err := convertNotificationTemplates(templates) - if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to convert retrieved notifications templates to marshalable form.", - Detail: err.Error(), - }) - return - } - + out := convertNotificationTemplates(templates) httpapi.Write(r.Context(), rw, http.StatusOK, out) } -func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate, err error) { +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: tmpl.Actions, + Actions: string(tmpl.Actions), Group: tmpl.Group.String, Method: string(tmpl.Method.NotificationMethod), IsSystem: tmpl.IsSystem, }) } - return out, nil + return out } diff --git a/codersdk/notifications.go b/codersdk/notifications.go index cffd53956a85c..505b54a3e2782 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -20,12 +20,14 @@ type NotificationTemplate struct { Name string `json:"name"` TitleTemplate string `json:"title_template"` BodyTemplate string `json:"body_template"` - Actions []byte `json:"actions"` + Actions string `json:"actions" format:""` Group string `json:"group"` Method string `json:"method"` IsSystem bool `json:"is_system"` } +// 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 { @@ -39,6 +41,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 { @@ -55,6 +59,8 @@ 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.MethodPost, fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateId), @@ -74,6 +80,7 @@ func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificat 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 { 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..577099f21d158 --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,135 @@ +# 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 notification templates pertaining to system events + +### 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", + "is_system": true, + "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) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» actions` | string | false | | | +| `» body_template` | string | false | | | +| `» group` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» is_system` | boolean | false | | | +| `» method` | string | false | | | +| `» name` | string | false | | | +| `» title_template` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 087f31b29e7fa..cbf5d1a8833f0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3136,6 +3136,34 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `id` | string | true | | | | `username` | string | true | | | +## codersdk.NotificationTemplate + +```json +{ + "actions": "string", + "body_template": "string", + "group": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_system": true, + "method": "string", + "name": "string", + "title_template": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------- | -------- | ------------ | ----------- | +| `actions` | string | false | | | +| `body_template` | string | false | | | +| `group` | string | false | | | +| `id` | string | false | | | +| `is_system` | boolean | false | | | +| `method` | string | false | | | +| `name` | string | false | | | +| `title_template` | string | false | | | + ## codersdk.NotificationsConfig ```json diff --git a/docs/manifest.json b/docs/manifest.json index 82dd73ada47c8..4b686ed9598b6 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -601,6 +601,10 @@ "title": "Members", "path": "./api/members.md" }, + { + "title": "Notifications", + "path": "./api/notifications.md" + }, { "title": "Organizations", "path": "./api/organizations.md" diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index e079ea18ff992..0ec221fd73b30 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -387,6 +387,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Post("/jfrog/xray-scan", api.postJFrogXrayScan) r.Get("/jfrog/xray-scan", api.jFrogXrayScan) }) + + // The /notifications base route is mounted by the AGPL router, so we can't group it here. + // Additionally, because we have a static route for /notifications/templates/system which conflicts + // with the below route, we need to register this route without any mounts or groups to make both work. r.With( apiKeyMiddleware, httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 87569738686c6..62b1d750397f1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -708,6 +708,18 @@ export interface MinimalUser { readonly avatar_url: string; } +// From codersdk/notifications.go +export interface NotificationTemplate { + readonly id: string; + readonly name: string; + readonly title_template: string; + readonly body_template: string; + readonly actions: string; + readonly group: string; + readonly method: string; + readonly is_system: boolean; +} + // From codersdk/deployment.go export interface NotificationsConfig { readonly max_send_attempts: number; From c1a42a78f29259721876e02de3f6aeb7b4bed566 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 10:41:43 +0200 Subject: [PATCH 06/13] Using "kind" not "is_system" Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- coderd/notifications.go | 4 ++-- codersdk/notifications.go | 2 +- docs/api/notifications.md | 4 ++-- docs/api/schemas.md | 22 +++++++++++----------- site/src/api/typesGenerated.ts | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index eee131fe3a021..19b7b38eb8bb7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10265,8 +10265,8 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, - "is_system": { - "type": "boolean" + "kind": { + "type": "string" }, "method": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9c5071c5fedba..c7cd4c07dbaf8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9198,8 +9198,8 @@ "type": "string", "format": "uuid" }, - "is_system": { - "type": "boolean" + "kind": { + "type": "string" }, "method": { "type": "string" diff --git a/coderd/notifications.go b/coderd/notifications.go index 04b801266f7b7..4853633e39ed7 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -131,7 +131,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request func (api *API) getSystemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - templates, err := api.Database.GetSystemNotificationTemplates(ctx) + templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to retrieve system notifications templates.", @@ -154,7 +154,7 @@ func convertNotificationTemplates(in []database.NotificationTemplate) (out []cod Actions: string(tmpl.Actions), Group: tmpl.Group.String, Method: string(tmpl.Method.NotificationMethod), - IsSystem: tmpl.IsSystem, + Kind: string(tmpl.Kind), }) } diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 505b54a3e2782..797bc8ac820d6 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -23,7 +23,7 @@ type NotificationTemplate struct { Actions string `json:"actions" format:""` Group string `json:"group"` Method string `json:"method"` - IsSystem bool `json:"is_system"` + Kind string `json:"kind"` } // GetNotificationsSettings retrieves the notifications settings, which currently just describes whether all diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 577099f21d158..434f3426237af 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -102,7 +102,7 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ "body_template": "string", "group": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "is_system": true, + "kind": "string", "method": "string", "name": "string", "title_template": "string" @@ -127,7 +127,7 @@ Status Code **200** | `» body_template` | string | false | | | | `» group` | string | false | | | | `» id` | string(uuid) | false | | | -| `» is_system` | boolean | false | | | +| `» kind` | string | false | | | | `» method` | string | false | | | | `» name` | string | false | | | | `» title_template` | string | false | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cbf5d1a8833f0..b50ce7b6b230d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3144,7 +3144,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "body_template": "string", "group": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "is_system": true, + "kind": "string", "method": "string", "name": "string", "title_template": "string" @@ -3153,16 +3153,16 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------- | -------- | ------------ | ----------- | -| `actions` | string | false | | | -| `body_template` | string | false | | | -| `group` | string | false | | | -| `id` | string | false | | | -| `is_system` | boolean | false | | | -| `method` | string | false | | | -| `name` | string | false | | | -| `title_template` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------ | -------- | ------------ | ----------- | +| `actions` | string | false | | | +| `body_template` | string | false | | | +| `group` | string | false | | | +| `id` | string | false | | | +| `kind` | string | false | | | +| `method` | string | false | | | +| `name` | string | false | | | +| `title_template` | string | false | | | ## codersdk.NotificationsConfig diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 62b1d750397f1..daa16a50454d4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -717,7 +717,7 @@ export interface NotificationTemplate { readonly actions: string; readonly group: string; readonly method: string; - readonly is_system: boolean; + readonly kind: string; } // From codersdk/deployment.go From 8bf48d47c804d287b72c0e726a8baadca653a8d1 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 11:28:59 +0200 Subject: [PATCH 07/13] Group routes in AGPL router Signed-off-by: Danny Kopping --- coderd/coderd.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 4906554b014ce..1f72c84e7cb17 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1249,7 +1249,9 @@ func New(options *Options) *API { ) r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) - r.Get("/templates/system", api.getSystemNotificationTemplates) + r.Route("/templates", func(r chi.Router) { + r.Get("/system", api.getSystemNotificationTemplates) + }) }) }) From 44a700b06240b48c1e6af7d1c15e5099c8707e8b Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 11:34:48 +0200 Subject: [PATCH 08/13] Use PUT since this endpoint is idempotent Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/coderd.go | 2 +- coderd/notifications.go | 2 +- codersdk/notifications.go | 2 +- docs/api/enterprise.md | 4 +- enterprise/coderd/coderd.go | 2 +- enterprise/coderd/notifications.go | 2 +- enterprise/coderd/notifications_test.go | 55 ------------------------- 9 files changed, 9 insertions(+), 64 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 19b7b38eb8bb7..1eb0349ec1035 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1641,7 +1641,7 @@ const docTemplate = `{ } }, "/notifications/templates/{notification_template}/method": { - "post": { + "put": { "security": [ { "CoderSessionToken": [] diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c7cd4c07dbaf8..d9af54257b617 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1424,7 +1424,7 @@ } }, "/notifications/templates/{notification_template}/method": { - "post": { + "put": { "security": [ { "CoderSessionToken": [] diff --git a/coderd/coderd.go b/coderd/coderd.go index 1f72c84e7cb17..b45e5219a74d0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1250,7 +1250,7 @@ func New(options *Options) *API { r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) r.Route("/templates", func(r chi.Router) { - r.Get("/system", api.getSystemNotificationTemplates) + r.Get("/system", api.systemNotificationTemplates) }) }) }) diff --git a/coderd/notifications.go b/coderd/notifications.go index 4853633e39ed7..6a6bfbe8a5a07 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -128,7 +128,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request // @Tags Notifications // @Success 200 {array} codersdk.NotificationTemplate // @Router /notifications/templates/system [get] -func (api *API) getSystemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { +func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 797bc8ac820d6..0e690615497d0 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -62,7 +62,7 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica // 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.MethodPost, + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateId), UpdateNotificationTemplateMethod{Method: method}, ) diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 9e4c7fbd9ce2f..a4de96701c817 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -543,11 +543,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \ +curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /notifications/templates/{notification_template}/method` +`PUT /notifications/templates/{notification_template}/method` ### Responses diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0ec221fd73b30..5fbd1569d0207 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -395,7 +395,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { apiKeyMiddleware, httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications), httpmw.ExtractNotificationTemplateParam(options.Database), - ).Post("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) + ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go index 038dc684cef27..bd51ac3d803c1 100644 --- a/enterprise/coderd/notifications.go +++ b/enterprise/coderd/notifications.go @@ -20,7 +20,7 @@ import ( // @Produce json // @Tags Enterprise // @Success 200 -// @Router /notifications/templates/{notification_template}/method [post] +// @Router /notifications/templates/{notification_template}/method [put] func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) { // TODO: authorization (restrict to admin/template admin?) var ( diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 9b4c5eccb4a27..9376b2efe85f4 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -148,61 +148,6 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { require.NotNil(t, template) require.Equal(t, method, template.Method) }) - - // t.Run("Settings modified", func(t *testing.T) { - // t.Parallel() - // - // client := coderdtest.New(t, nil) - // _ = coderdtest.CreateFirstUser(t, client) - // - // // given - // expected := codersdk.NotificationsSettings{ - // NotifierPaused: true, - // } - // - // ctx := testutil.Context(t, testutil.WaitShort) - // - // // when - // err := client.PutNotificationsSettings(ctx, expected) - // require.NoError(t, err) - // - // // then - // actual, err := client.GetNotificationsSettings(ctx) - // require.NoError(t, err) - // require.Equal(t, expected, actual) - // }) - // - // t.Run("Settings not modified", func(t *testing.T) { - // t.Parallel() - // - // // Empty state: notifications Settings are undefined now (default). - // client := coderdtest.New(t, nil) - // _ = coderdtest.CreateFirstUser(t, client) - // ctx := testutil.Context(t, testutil.WaitShort) - // - // // Change the state: pause notifications - // err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ - // NotifierPaused: true, - // }) - // require.NoError(t, err) - // - // // Verify the state: notifications are paused. - // actual, err := client.GetNotificationsSettings(ctx) - // require.NoError(t, err) - // require.True(t, actual.NotifierPaused) - // - // // Change the stage again: notifications are paused. - // expected := actual - // err = client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ - // NotifierPaused: true, - // }) - // require.NoError(t, err) - // - // // Verify the state: notifications are still paused, and there is no error returned. - // actual, err = client.GetNotificationsSettings(ctx) - // require.NoError(t, err) - // require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) - // }) } func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) { From 905958cac29285314e6717fd89934d875db6de56 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 15:18:26 +0200 Subject: [PATCH 09/13] Only use pg when necessary Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/notifications.go | 13 +------ docs/api/notifications.md | 4 +- enterprise/coderd/notifications_test.go | 49 ++++++++++++++----------- 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1eb0349ec1035..8fdcd232c0958 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1625,8 +1625,8 @@ const docTemplate = `{ "tags": [ "Notifications" ], - "summary": "Get notification templates pertaining to system events", - "operationId": "system-notification-templates", + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", "responses": { "200": { "description": "OK", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d9af54257b617..2abb9f262ddb1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1408,8 +1408,8 @@ ], "produces": ["application/json"], "tags": ["Notifications"], - "summary": "Get notification templates pertaining to system events", - "operationId": "system-notification-templates", + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", "responses": { "200": { "description": "OK", diff --git a/coderd/notifications.go b/coderd/notifications.go index 6a6bfbe8a5a07..caacfec2ef52a 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -10,8 +10,6 @@ import ( "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/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -59,13 +57,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 @@ -121,8 +112,8 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request httpapi.Write(r.Context(), rw, http.StatusOK, settings) } -// @Summary Get notification templates pertaining to system events -// @ID system-notification-templates +// @Summary Get system notification templates +// @ID get-system-notification-templates // @Security CoderSessionToken // @Produce json // @Tags Notifications diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 434f3426237af..ca43565a982a9 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -78,7 +78,7 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get notification templates pertaining to system events +## Get system notification templates ### Code samples @@ -116,7 +116,7 @@ curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ | ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | -

Response Schema

+

Response Schema

Status Code **200** diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 9376b2efe85f4..7ada95292b8f1 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -12,27 +12,40 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/testutil" ) -func createOpts(t *testing.T) *coderdenttest.Options { +func createOpts(t *testing.T, usePostgres bool) *coderdenttest.Options { t.Helper() - db, ps := dbtestutil.NewDB(t) + var ( + db database.Store + ps pubsub.Pubsub + ) + + if usePostgres { + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") + } + + db, ps = dbtestutil.NewDB(t) + } else { + db, ps = dbmem.New(), pubsub.NewInMemory() + } dt := coderdtest.DeploymentValues(t) dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdenttest.Options{ Options: &coderdtest.Options{ - DeploymentValues: dt, - - IncludeProvisionerDaemon: true, - Database: db, - Pubsub: ps, + DeploymentValues: dt, + Database: db, + Pubsub: ps, }, } } @@ -40,19 +53,14 @@ func createOpts(t *testing.T) *coderdenttest.Options { func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") - } - t.Run("Happy path", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitSuperLong) - - api, _ := coderdenttest.New(t, createOpts(t)) + api, _ := coderdenttest.New(t, createOpts(t, true)) var ( - method = string(database.NotificationMethodSmtp) + method = string(database.NotificationMethodSmtp) templateID = notifications.TemplateWorkspaceDeleted ) @@ -75,10 +83,10 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Insufficient permissions", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitSuperLong) + ctx := testutil.Context(t, testutil.WaitShort) // Given: the first user which has an "owner" role, and another user which does not. - api, firstUser := coderdenttest.New(t, createOpts(t)) + api, firstUser := coderdenttest.New(t, createOpts(t, false)) anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) // When: calling the API as an unprivileged user. @@ -98,7 +106,7 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { ctx := testutil.Context(t, testutil.WaitSuperLong) // Given: the first user which has an "owner" role - api, _ := coderdenttest.New(t, createOpts(t)) + api, _ := coderdenttest.New(t, createOpts(t, true)) // When: calling the API with an invalid method. const method = "nope" @@ -119,11 +127,10 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitSuperLong) - - api, _ := coderdenttest.New(t, createOpts(t)) + api, _ := coderdenttest.New(t, createOpts(t, true)) var ( - method = string(database.NotificationMethodSmtp) + method = string(database.NotificationMethodSmtp) templateID = notifications.TemplateWorkspaceDeleted ) @@ -170,4 +177,4 @@ func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id } return template, nil -} \ No newline at end of file +} From 203ec6d23e78afbc91dd7bcdbf199f0d873abb45 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 17:17:06 +0200 Subject: [PATCH 10/13] Fix notification settings test Signed-off-by: Danny Kopping --- coderd/notifications.go | 20 +++++++++++++------- coderd/notifications_test.go | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index caacfec2ef52a..0545f19ec2026 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -10,6 +10,7 @@ import ( "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/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -71,9 +72,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(), }) @@ -82,7 +83,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 } @@ -102,10 +103,15 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON)) if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update notifications settings.", - Detail: err.Error(), - }) + 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 } diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 7690154a0db80..19bc3044f8d68 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -11,13 +11,23 @@ import ( "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 +51,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 +75,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) From 1ec84a34d498e5025d634f623b36792a02cc53a7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 23 Jul 2024 17:31:38 +0200 Subject: [PATCH 11/13] Test database business logic Signed-off-by: Danny Kopping --- coderd/notifications/enqueuer.go | 7 ++ coderd/notifications/manager.go | 16 +++- coderd/notifications/notifications_test.go | 88 ++++++++++++++++++++++ coderd/notifications/notifier.go | 17 +++++ 4 files changed, 125 insertions(+), 3 deletions(-) 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. From 4ba8c2a49b83f35ee4d23c9e95de952ebc398702 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 16:37:44 +0200 Subject: [PATCH 12/13] Basic implementation Signed-off-by: Danny Kopping --- coderd/coderd.go | 4 ++ coderd/notifications.go | 96 ++++++++++++++++++++++++++++++++++++ coderd/notifications_test.go | 50 +++++++++++++++++++ codersdk/notifications.go | 61 +++++++++++++++++++++++ 4 files changed, 211 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index b45e5219a74d0..8d819a4273706 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1252,6 +1252,10 @@ func New(options *Options) *API { r.Route("/templates", func(r chi.Router) { r.Get("/system", api.systemNotificationTemplates) }) + r.Route("/preferences", func(r chi.Router) { + r.Get("/", api.userNotificationPreferences) + r.Put("/", api.putUserNotificationPreferences) + }) }) }) diff --git a/coderd/notifications.go b/coderd/notifications.go index 0545f19ec2026..209b3eaea48a1 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -141,6 +141,90 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } +// @Summary TODO Get notification templates pertaining to system events +// @ID TODO system-notification-templates +// @Security TODO CoderSessionToken +// @Produce TODO json +// @Tags TODO Notifications +// @Success TODO 200 {array} codersdk.NotificationTemplate +// @Router TODO /notifications/templates/system [get] +func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + key := httpmw.APIKey(r) + + if !api.Authorize(r, policy.ActionReadPersonal, rbac.ResourceNotificationPreference) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Insufficient permissions to update notification preferences.", + }) + return + } + + prefs, err := api.Database.GetUserNotificationPreferences(ctx, key.UserID) + if err != nil { + 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 TODO Get notification templates pertaining to system events +// @ID TODO system-notification-templates +// @Security TODO CoderSessionToken +// @Produce TODO json +// @Tags TODO Notifications +// @Success TODO 200 {array} codersdk.NotificationTemplate +// @Router TODO /notifications/templates/system [put] +func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceNotificationPreference) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Insufficient permissions to update notification preferences.", + }) + return + } + + var prefs codersdk.UpdateUserNotificationPreferences + if !httpapi.Read(ctx, rw, r, &prefs) { + return + } + + input := database.UpdateUserNotificationPreferencesParams{ + 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 { + 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 { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications preferences.", + Detail: err.Error(), + }) + return + } + + out := convertNotificationPreferences(updated) + 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{ @@ -157,3 +241,15 @@ func convertNotificationTemplates(in []database.NotificationTemplate) (out []cod 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_test.go b/coderd/notifications_test.go index 19bc3044f8d68..866b7e861b67b 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -7,6 +7,7 @@ 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" ) @@ -103,3 +104,52 @@ 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) + + // Given: the first user in its initial state. + client := coderdtest.New(t, createOpts(t)) + _ = coderdtest.CreateFirstUser(t, client) + + // When: calling the API. + prefs, err := client.GetUserNotificationPreferences(ctx) + require.NoError(t, err) + + // Then: no preferences will be returned. + require.Len(t, prefs, 0) + }) + + t.Run("Disable a template", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + template := notifications.TemplateWorkspaceDormant + + // Given: the first user with no set preferences. + client := coderdtest.New(t, createOpts(t)) + _ = coderdtest.CreateFirstUser(t, client) + + prefs, err := client.GetUserNotificationPreferences(ctx) + require.NoError(t, err) + require.Len(t, prefs, 0) + + // When: calling the API. + prefs, err = client.UpdateUserNotificationPreferences(ctx, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + template.String(): true, + }, + }) + require.NoError(t, err) + + // Then: the single preference will be returned. + require.Len(t, prefs, 1) + require.Equal(t, template, prefs[0].NotificationTemplateID) + require.True(t, prefs[0].Disabled) + }) +} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 0e690615497d0..1f0232695a096 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -26,6 +27,12 @@ type NotificationTemplate struct { 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) { @@ -105,6 +112,60 @@ func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]Notifica return templates, nil } +// GetUserNotificationPreferences TODO +func (c *Client) GetUserNotificationPreferences(ctx context.Context) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/preferences", 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, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/preferences", 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"` +} From 9398ce9454a427c8c8c8aa019efe0dd5fd6a75ff Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 17:18:56 +0200 Subject: [PATCH 13/13] API finalization + tests Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 111 +++++++++++++++++++++++++ coderd/apidoc/swagger.json | 101 +++++++++++++++++++++++ coderd/coderd.go | 10 ++- coderd/database/modelmethods.go | 4 + coderd/notifications.go | 88 ++++++++++++-------- coderd/notifications_test.go | 142 ++++++++++++++++++++++++++++---- codersdk/notifications.go | 8 +- docs/api/notifications.md | 117 ++++++++++++++++++++++++++ docs/api/schemas.md | 36 ++++++++ site/src/api/typesGenerated.ts | 12 +++ 10 files changed, 572 insertions(+), 57 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8fdcd232c0958..cda35b2f2bfd2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5404,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": [ @@ -10249,6 +10333,22 @@ 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": { @@ -12594,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 2abb9f262ddb1..a73cdc36a55fe 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4768,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": [ @@ -9182,6 +9256,22 @@ } } }, + "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": { @@ -11435,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 8d819a4273706..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) + }) + }) }) }) }) @@ -1252,10 +1258,6 @@ func New(options *Options) *API { r.Route("/templates", func(r chi.Router) { r.Get("/system", api.systemNotificationTemplates) }) - r.Route("/preferences", func(r chi.Router) { - r.Get("/", api.userNotificationPreferences) - r.Put("/", api.putUserNotificationPreferences) - }) }) }) 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/notifications.go b/coderd/notifications.go index 209b3eaea48a1..3931606a5612d 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -7,9 +7,12 @@ 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/codersdk" ) @@ -141,26 +144,25 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } -// @Summary TODO Get notification templates pertaining to system events -// @ID TODO system-notification-templates -// @Security TODO CoderSessionToken -// @Produce TODO json -// @Tags TODO Notifications -// @Success TODO 200 {array} codersdk.NotificationTemplate -// @Router TODO /notifications/templates/system [get] +// @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) { - ctx := r.Context() - key := httpmw.APIKey(r) + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID)) + ) - if !api.Authorize(r, policy.ActionReadPersonal, rbac.ResourceNotificationPreference) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Insufficient permissions to update notification preferences.", - }) - return - } - - prefs, err := api.Database.GetUserNotificationPreferences(ctx, key.UserID) + 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(), @@ -172,22 +174,22 @@ func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } -// @Summary TODO Get notification templates pertaining to system events -// @ID TODO system-notification-templates -// @Security TODO CoderSessionToken -// @Produce TODO json -// @Tags TODO Notifications -// @Success TODO 200 {array} codersdk.NotificationTemplate -// @Router TODO /notifications/templates/system [put] +// @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) { - ctx := r.Context() - - if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceNotificationPreference) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Insufficient permissions to update notification preferences.", - }) - return - } + 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) { @@ -195,12 +197,15 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R } 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(), @@ -214,14 +219,29 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R 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 update notifications preferences.", + Message: "Failed to retrieve user notifications preferences.", Detail: err.Error(), }) return } - out := convertNotificationPreferences(updated) + out := convertNotificationPreferences(userPrefs) httpapi.Write(r.Context(), rw, http.StatusOK, out) } diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 866b7e861b67b..77d63d00a32c9 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -112,44 +112,156 @@ func TestNotificationPreferences(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) - // Given: the first user in its initial state. - client := coderdtest.New(t, createOpts(t)) - _ = coderdtest.CreateFirstUser(t, client) + // Given: a member in its initial state. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) // When: calling the API. - prefs, err := client.GetUserNotificationPreferences(ctx) + prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) require.NoError(t, err) // Then: no preferences will be returned. require.Len(t, prefs, 0) }) - t.Run("Disable a template", func(t *testing.T) { + t.Run("Insufficient permissions", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) - template := notifications.TemplateWorkspaceDormant + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) - // Given: the first user with no set preferences. - client := coderdtest.New(t, createOpts(t)) - _ = coderdtest.CreateFirstUser(t, client) + // 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) - prefs, err := client.GetUserNotificationPreferences(ctx) + // 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) + }) - // When: calling the API. - prefs, err = client.UpdateUserNotificationPreferences(ctx, codersdk.UpdateUserNotificationPreferences{ + 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{ - template.String(): true, + 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) - // Then: the single preference will be returned. + // 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, template, prefs[0].NotificationTemplateID) + 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/notifications.go b/codersdk/notifications.go index 1f0232695a096..32aad524f79f4 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -113,8 +113,8 @@ func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]Notifica } // GetUserNotificationPreferences TODO -func (c *Client) GetUserNotificationPreferences(ctx context.Context) ([]NotificationPreference, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/preferences", nil) +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 } @@ -138,8 +138,8 @@ func (c *Client) GetUserNotificationPreferences(ctx context.Context) ([]Notifica } // UpdateUserNotificationPreferences TODO -func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) { - res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/preferences", req) +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 } diff --git a/docs/api/notifications.md b/docs/api/notifications.md index ca43565a982a9..25ef607acca8f 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -133,3 +133,120 @@ Status Code **200** | `» title_template` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get user notification preferences + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/notifications/preferences \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/notifications/preferences` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------ | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +[ + { + "disabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» disabled` | boolean | false | | | +| `» id` | string(uuid) | false | | | +| `» updated_at` | string(date-time) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update user notification preferences + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/notifications/preferences \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/notifications/preferences` + +> Body parameter + +```json +{ + "template_disabled_map": { + "property1": true, + "property2": true + } +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------------------------------- | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.UpdateUserNotificationPreferences](schemas.md#codersdkupdateusernotificationpreferences) | true | Preferences | + +### Example responses + +> 200 Response + +```json +[ + { + "disabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» disabled` | boolean | false | | | +| `» id` | string(uuid) | false | | | +| `» updated_at` | string(date-time) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b50ce7b6b230d..ac32d5798eba0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3136,6 +3136,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `id` | string | true | | | | `username` | string | true | | | +## codersdk.NotificationPreference + +```json +{ + "disabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------- | -------- | ------------ | ----------- | +| `disabled` | boolean | false | | | +| `id` | string | false | | | +| `updated_at` | string | false | | | + ## codersdk.NotificationTemplate ```json @@ -5560,6 +5578,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | ------------------ | ------ | -------- | ------------ | ----------- | | `theme_preference` | string | true | | | +## codersdk.UpdateUserNotificationPreferences + +```json +{ + "template_disabled_map": { + "property1": true, + "property2": true + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------------- | ------- | -------- | ------------ | ----------- | +| `template_disabled_map` | object | false | | | +| » `[any property]` | boolean | false | | | + ## codersdk.UpdateUserPasswordRequest ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index daa16a50454d4..5c9bde2e1767d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -708,6 +708,13 @@ export interface MinimalUser { readonly avatar_url: string; } +// From codersdk/notifications.go +export interface NotificationPreference { + readonly id: string; + readonly disabled: boolean; + readonly updated_at: string; +} + // From codersdk/notifications.go export interface NotificationTemplate { readonly id: string; @@ -1502,6 +1509,11 @@ export interface UpdateUserAppearanceSettingsRequest { readonly theme_preference: string; } +// From codersdk/notifications.go +export interface UpdateUserNotificationPreferences { + readonly template_disabled_map: Record; +} + // From codersdk/users.go export interface UpdateUserPasswordRequest { readonly old_password: string;