From 2f78056f0d9903e4d663c3b159586aab49d5f2a4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 25 Jul 2024 22:00:50 +0200 Subject: [PATCH 01/17] 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 6671fdb796836..917d73ac963a4 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 02d0247df7362..858cd57adf526 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 804ee8ce8cec0..7b5ec49cd87cc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1447,6 +1447,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 09da48f4009b3d32ac92a0fca3c9b9fd55e47841 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 08:56:09 +0200 Subject: [PATCH 02/17] 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 31da8113ef00e38fe385151939a71ad7e9a2f159 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 09:48:53 +0200 Subject: [PATCH 03/17] 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 917d73ac963a4..6ac4a792bb1fd 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": [ @@ -10224,6 +10252,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 858cd57adf526..74036cefc2c8e 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": [ @@ -9161,6 +9185,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 e0d208895eb75..bd2d8f3e3673b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3141,6 +3141,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 7b5ec49cd87cc..7549c9586ad7c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -709,6 +709,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 02fbaaf7611ea69649ffd8a50ccaf2a03460f2d7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 10:41:43 +0200 Subject: [PATCH 04/17] 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 6ac4a792bb1fd..f782703fd1431 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10268,8 +10268,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 74036cefc2c8e..4b6dea4706b4c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9201,8 +9201,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 bd2d8f3e3673b..7e28faa047a8f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3149,7 +3149,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" @@ -3158,16 +3158,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 7549c9586ad7c..5e49dc1698246 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -718,7 +718,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 2d4dc1734d44e2fcfe7b843580c00d2767ef15a4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 11:28:59 +0200 Subject: [PATCH 05/17] 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 1d84f4ac461a3231cd2a44261d17596d41278d00 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 29 Jul 2024 11:34:48 +0200 Subject: [PATCH 06/17] 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 f782703fd1431..0d74087deda25 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 4b6dea4706b4c..33101d22070c3 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 f77c2c505cdfb37b7dc0ba7c22e44e2dac9301ed Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 15:18:26 +0200 Subject: [PATCH 07/17] 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 0d74087deda25..111ce0e0cf22c 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 33101d22070c3..175a890f30da7 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 0546f24bec2a818f5ac53d11283b5c9723418c0e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 1 Aug 2024 17:17:06 +0200 Subject: [PATCH 08/17] 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 c2f024ee1c91b460083e13f3d73f1491dd036fbb Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 2 Aug 2024 16:02:37 +0200 Subject: [PATCH 09/17] Self-review Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 5 ++++- coderd/apidoc/swagger.json | 5 ++++- coderd/httpmw/notificationtemplateparam.go | 4 ++-- docs/api/enterprise.md | 7 ++++--- enterprise/coderd/notifications.go | 6 +++--- enterprise/coderd/notifications_test.go | 15 +++------------ 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 111ce0e0cf22c..f16882df51b4b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1654,10 +1654,13 @@ const docTemplate = `{ "Enterprise" ], "summary": "Update notification template dispatch method", - "operationId": "post-notification-template-method", + "operationId": "put-notification-template-method", "responses": { "200": { "description": "OK" + }, + "304": { + "description": "Not Modified" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 175a890f30da7..2fead38f11efd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1433,10 +1433,13 @@ "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Update notification template dispatch method", - "operationId": "post-notification-template-method", + "operationId": "put-notification-template-method", "responses": { "200": { "description": "OK" + }, + "304": { + "description": "Not Modified" } } } diff --git a/coderd/httpmw/notificationtemplateparam.go b/coderd/httpmw/notificationtemplateparam.go index ae86d48d033e8..5466c3b7403d9 100644 --- a/coderd/httpmw/notificationtemplateparam.go +++ b/coderd/httpmw/notificationtemplateparam.go @@ -15,7 +15,7 @@ type notificationTemplateParamContextKey struct{} 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") + panic("developer error: notification template middleware not used") } return template } @@ -29,7 +29,7 @@ func ExtractNotificationTemplateParam(db database.Store) func(http.Handler) http if !parsed { return } - nt, err := db.GetNotificationTemplateById(r.Context(), notifTemplateID) + nt, err := db.GetNotificationTemplateByID(r.Context(), notifTemplateID) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index a4de96701c817..b25ad74409130 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -551,9 +551,10 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notificatio ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ------------ | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | +| 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). diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go index bd51ac3d803c1..811fa82e29f37 100644 --- a/enterprise/coderd/notifications.go +++ b/enterprise/coderd/notifications.go @@ -15,14 +15,14 @@ import ( ) // @Summary Update notification template dispatch method -// @ID post-notification-template-method +// @ID put-notification-template-method // @Security CoderSessionToken // @Produce json // @Tags Enterprise // @Success 200 +// @Success 304 // @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 ( ctx = r.Context() template = httpmw.NotificationTemplateParam(r) @@ -74,7 +74,7 @@ func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http err := api.Database.InTx(func(tx database.Store) error { var err error - template, err = api.Database.UpdateNotificationTemplateMethodById(r.Context(), database.UpdateNotificationTemplateMethodByIdParams{ + template, err = api.Database.UpdateNotificationTemplateMethodByID(r.Context(), database.UpdateNotificationTemplateMethodByIDParams{ ID: template.ID, Method: nm, }) diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 7ada95292b8f1..5874e2114dfd7 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -12,9 +12,7 @@ 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" @@ -24,21 +22,14 @@ import ( func createOpts(t *testing.T, usePostgres bool) *coderdenttest.Options { t.Helper() - 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() } + db, ps := dbtestutil.NewDB(t) + dt := coderdtest.DeploymentValues(t) dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdenttest.Options{ @@ -83,7 +74,7 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Insufficient permissions", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) + 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, false)) From a9093296063d69bf49ced0bc89984b9e0cbe1962 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 2 Aug 2024 16:37:18 +0200 Subject: [PATCH 10/17] CI Signed-off-by: Danny Kopping --- codersdk/notifications.go | 4 +- enterprise/coderd/notifications_test.go | 59 ++++++++++++++----------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 0e690615497d0..49cf589ba895c 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -61,9 +61,9 @@ 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 { +func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateID uuid.UUID, method string) error { res, err := c.Request(ctx, http.MethodPut, - fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateId), + fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateID), UpdateNotificationTemplateMethod{Method: method}, ) if err != nil { diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 5874e2114dfd7..5546bec1dcb79 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -19,24 +19,14 @@ import ( "github.com/coder/coder/v2/testutil" ) -func createOpts(t *testing.T, usePostgres bool) *coderdenttest.Options { +func createOpts(t *testing.T) *coderdenttest.Options { t.Helper() - 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) - dt := coderdtest.DeploymentValues(t) dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dt, - Database: db, - Pubsub: ps, }, } } @@ -47,8 +37,12 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Happy path", func(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") + } + ctx := testutil.Context(t, testutil.WaitSuperLong) - api, _ := coderdenttest.New(t, createOpts(t, true)) + api, _ := coderdenttest.New(t, createOpts(t)) var ( method = string(database.NotificationMethodSmtp) @@ -56,7 +50,7 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { ) // Given: a template whose method is initially empty (i.e. deferring to the global method value). - template, err := getTemplateById(t, ctx, api, templateID) + template, err := getTemplateByID(t, ctx, api, templateID) require.NoError(t, err) require.NotNil(t, template) require.Empty(t, template.Method) @@ -65,7 +59,7 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { 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) + template, err = getTemplateByID(t, ctx, api, templateID) require.NoError(t, err) require.NotNil(t, template) require.Equal(t, method, template.Method) @@ -74,10 +68,14 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Insufficient permissions", func(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") + } + 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, false)) + api, firstUser := coderdenttest.New(t, createOpts(t)) anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) // When: calling the API as an unprivileged user. @@ -94,13 +92,19 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Invalid notification method", func(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") + } + ctx := testutil.Context(t, testutil.WaitSuperLong) // Given: the first user which has an "owner" role - api, _ := coderdenttest.New(t, createOpts(t, true)) + api, _ := coderdenttest.New(t, createOpts(t)) // When: calling the API with an invalid method. const method = "nope" + + // nolint:gocritic // Using an owner-scope user is kinda the point. err := api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method) // Then: the request is invalid because of the unacceptable method. @@ -117,15 +121,19 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Not modified", func(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") + } + ctx := testutil.Context(t, testutil.WaitSuperLong) - api, _ := coderdenttest.New(t, createOpts(t, true)) + api, _ := coderdenttest.New(t, createOpts(t)) var ( method = string(database.NotificationMethodSmtp) templateID = notifications.TemplateWorkspaceDeleted ) - template, err := getTemplateById(t, ctx, api, templateID) + template, err := getTemplateByID(t, ctx, api, templateID) require.NoError(t, err) require.NotNil(t, template) @@ -134,24 +142,25 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { // 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) + 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) + template, err = getTemplateByID(t, ctx, api, templateID) require.NoError(t, err) require.NotNil(t, template) require.Equal(t, method, template.Method) }) } -func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) { +// nolint:revive // t takes precedence. +func getTemplateByID(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) { t.Helper() - var template *codersdk.NotificationTemplate + var template codersdk.NotificationTemplate templates, err := api.GetSystemNotificationTemplates(ctx) if err != nil { return nil, err @@ -159,13 +168,13 @@ func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id for _, tmpl := range templates { if tmpl.ID == id { - template = &tmpl + template = tmpl } } - if template == nil { + if template.ID == uuid.Nil { return nil, xerrors.Errorf("template not found: %q", id.String()) } - return template, nil + return &template, nil } From 821fdf5d034e51729f0ed6c760b4dfe5cb578129 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 2 Aug 2024 17:38:17 +0200 Subject: [PATCH 11/17] Dispatch notification using custom method Signed-off-by: Danny Kopping --- coderd/database/queries.sql.go | 20 +++-- coderd/database/queries/notifications.sql | 6 +- coderd/notifications/enqueuer.go | 41 +++++---- coderd/notifications/manager.go | 2 +- coderd/notifications/notifications_test.go | 100 ++++++++++++++++++++- coderd/notifications/notifier.go | 38 ++++---- 6 files changed, 156 insertions(+), 51 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b635ee0fd40fe..d8a6e3a1abb03 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3540,10 +3540,11 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one SELECT nt.name AS notification_name, nt.actions AS actions, + nt.method AS custom_method, u.id AS user_id, u.email AS user_email, COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name, - COALESCE(u.username, '') AS user_username + u.username AS user_username FROM notification_templates nt, users u WHERE nt.id = $1 @@ -3556,12 +3557,13 @@ type FetchNewMessageMetadataParams struct { } type FetchNewMessageMetadataRow struct { - NotificationName string `db:"notification_name" json:"notification_name"` - Actions []byte `db:"actions" json:"actions"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - UserEmail string `db:"user_email" json:"user_email"` - UserName string `db:"user_name" json:"user_name"` - UserUsername string `db:"user_username" json:"user_username"` + NotificationName string `db:"notification_name" json:"notification_name"` + Actions []byte `db:"actions" json:"actions"` + CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + UserEmail string `db:"user_email" json:"user_email"` + UserName string `db:"user_name" json:"user_name"` + UserUsername string `db:"user_username" json:"user_username"` } // This is used to build up the notification_message's JSON payload. @@ -3571,6 +3573,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe err := row.Scan( &i.NotificationName, &i.Actions, + &i.CustomMethod, &i.UserID, &i.UserEmail, &i.UserName, @@ -3653,7 +3656,8 @@ func (q *sqlQuerier) GetNotificationTemplateByID(ctx context.Context, id uuid.UU } const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind :many -SELECT id, name, title_template, body_template, actions, "group", method, kind FROM notification_templates +SELECT id, name, title_template, body_template, actions, "group", method, kind +FROM notification_templates WHERE kind = $1::notification_template_kind ` diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index d62564ea6edbf..f5b8601871ccc 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -2,10 +2,11 @@ -- This is used to build up the notification_message's JSON payload. SELECT nt.name AS notification_name, nt.actions AS actions, + nt.method AS custom_method, u.id AS user_id, u.email AS user_email, COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name, - COALESCE(u.username, '') AS user_username + u.username AS user_username FROM notification_templates nt, users u WHERE nt.id = @notification_template_id @@ -167,5 +168,6 @@ FROM notification_templates WHERE id = @id::uuid; -- name: GetNotificationTemplatesByKind :many -SELECT * FROM notification_templates +SELECT * +FROM notification_templates WHERE kind = @kind::notification_template_kind; diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index 32822dd6ab9d7..3585f28007e67 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -20,10 +20,7 @@ type StoreEnqueuer struct { store Store log slog.Logger - // TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none. - // For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants - // Slack notifications, and Mary doesn't want any. - method database.NotificationMethod + defaultMethod database.NotificationMethod // helpers holds a map of template funcs which are used when rendering templates. These need to be passed in because // the template funcs will return values which are inappropriately encapsulated in this struct. helpers template.FuncMap @@ -37,17 +34,31 @@ func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers tem } return &StoreEnqueuer{ - store: store, - log: log, - method: method, - helpers: helpers, + store: store, + log: log, + defaultMethod: method, + helpers: helpers, }, nil } // Enqueue queues a notification message for later delivery. // Messages will be dequeued by a notifier later and dispatched. func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { - payload, err := s.buildPayload(ctx, userID, templateID, labels) + metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ + UserID: userID, + NotificationTemplateID: templateID, + }) + if err != nil { + s.log.Warn(ctx, "failed to fetch message metadata", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) + return nil, xerrors.Errorf("new message metadata: %w", err) + } + + dispatchMethod := s.defaultMethod + if metadata.CustomMethod.Valid { + dispatchMethod = metadata.CustomMethod.NotificationMethod + } + + payload, err := s.buildPayload(metadata, labels) if err != nil { s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) @@ -63,7 +74,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI ID: id, UserID: userID, NotificationTemplateID: templateID, - Method: s.method, + Method: dispatchMethod, Payload: input, Targets: targets, CreatedBy: createdBy, @@ -80,15 +91,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI // buildPayload creates the payload that the notification will for variable substitution and/or routing. // The payload contains information about the recipient, the event that triggered the notification, and any subsequent // actions which can be taken by the recipient. -func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) { - metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ - UserID: userID, - NotificationTemplateID: templateID, - }) - if err != nil { - return nil, xerrors.Errorf("new message metadata: %w", err) - } - +func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) { payload := types.MessagePayload{ Version: "1.0", diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 5f5d30974a302..51daa9fc4742c 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -149,7 +149,7 @@ func (m *Manager) loop(ctx context.Context) error { var eg errgroup.Group // Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications. - m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.method, m.metrics) + m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.metrics) eg.Go(func() error { return m.notifier.run(ctx, m.success, m.failure) }) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 37fe4a2ce5ce3..0f3312f66c386 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -604,7 +604,7 @@ func TestNotifierPaused(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } -func TestNotifcationTemplatesBody(t *testing.T) { +func TestNotificationTemplatesBody(t *testing.T) { t.Parallel() if !dbtestutil.WillUsePostgres() { @@ -705,6 +705,104 @@ func TestNotifcationTemplatesBody(t *testing.T) { } } +func TestCustomNotificationMethod(t *testing.T) { + t.Parallel() + + // SETUP + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") + } + + ctx, logger, db := setup(t) + + received := make(chan uuid.UUID, 1) + + // SETUP: + // Start mock server to simulate webhook endpoint. + mockWebhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload dispatch.WebhookPayload + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + + received <- payload.MsgID + close(received) + + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("noted.")) + require.NoError(t, err) + })) + defer mockWebhookSrv.Close() + + // Start mock SMTP server. + mockSMTPSrv := smtpmock.New(smtpmock.ConfigurationAttr{ + LogToStdout: false, + LogServerActivity: true, + }) + require.NoError(t, mockSMTPSrv.Start()) + t.Cleanup(func() { + assert.NoError(t, mockSMTPSrv.Stop()) + }) + + endpoint, err := url.Parse(mockWebhookSrv.URL) + require.NoError(t, err) + + // GIVEN: a notification template which has a method explicitly set + var ( + template = notifications.TemplateWorkspaceDormant + defaultMethod = database.NotificationMethodSmtp + customMethod = database.NotificationMethodWebhook + ) + out, err := db.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{ + ID: template, + Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, customMethod, out.Method.NotificationMethod) + + // GIVEN: a manager configured with multiple dispatch methods + cfg := defaultNotificationsConfig(defaultMethod) + cfg.SMTP = codersdk.NotificationsEmailConfig{ + From: "danny@coder.com", + Hello: "localhost", + Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())}, + } + cfg.Webhook = codersdk.NotificationsWebhookConfig{ + Endpoint: *serpent.URLOf(endpoint), + } + + mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager")) + require.NoError(t, err) + t.Cleanup(func() { + _ = mgr.Stop(ctx) + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger) + require.NoError(t, err) + + // WHEN: a notification of that template is enqueued, it should be delivered with the configured method - not the default. + user := createSampleUser(t, db) + msgID, err := enq.Enqueue(ctx, user.ID, template, map[string]string{}, "test") + + // THEN: the notification should be received by the custom dispatch method + mgr.Run(ctx) + + receivedMsgID := testutil.RequireRecvCtx(ctx, t, received) + require.Equal(t, msgID.String(), receivedMsgID.String()) + + // Ensure no messages received by default method (SMTP): + msgs := mockSMTPSrv.MessagesAndPurge() + require.Len(t, msgs, 0) + + // Enqueue a notification which does not have a custom method set to ensure default works correctly. + msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test") + require.NoError(t, err) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + msgs := mockSMTPSrv.MessagesAndPurge() + assert.Len(ct, msgs, 1) + assert.Contains(ct, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID)) + }, testutil.WaitLong, testutil.IntervalFast) +} + type fakeHandler struct { mu sync.RWMutex succeeded, failed []string diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index c39de6168db81..df10c4e08e259 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -33,23 +33,21 @@ type notifier struct { quit chan any done chan any - method database.NotificationMethod - handlers map[database.NotificationMethod]Handler + handlers map[database.NotificationMethod]Handler metrics *Metrics } -func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, method database.NotificationMethod, metrics *Metrics) *notifier { +func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, metrics *Metrics) *notifier { return ¬ifier{ - id: id, - cfg: cfg, - log: log.Named("notifier").With(slog.F("notifier_id", id)), - quit: make(chan any), - done: make(chan any), - tick: time.NewTicker(cfg.FetchInterval.Value()), - store: db, - handlers: hr, - method: method, - metrics: metrics, + id: id, + cfg: cfg, + log: log.Named("notifier").With(slog.F("notifier_id", id)), + quit: make(chan any), + done: make(chan any), + tick: time.NewTicker(cfg.FetchInterval.Value()), + store: db, + handlers: hr, + metrics: metrics, } } @@ -234,17 +232,17 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification logger := n.log.With(slog.F("msg_id", msg.ID), slog.F("method", msg.Method), slog.F("attempt", msg.AttemptCount+1)) if msg.AttemptCount > 0 { - n.metrics.RetryCount.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc() + n.metrics.RetryCount.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc() } - n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc() - n.metrics.QueuedSeconds.WithLabelValues(string(n.method)).Observe(msg.QueuedSeconds) + n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc() + n.metrics.QueuedSeconds.WithLabelValues(string(msg.Method)).Observe(msg.QueuedSeconds) start := time.Now() retryable, err := deliver(ctx, msg.ID) - n.metrics.DispatcherSendSeconds.WithLabelValues(string(n.method)).Observe(time.Since(start).Seconds()) - n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Dec() + n.metrics.DispatcherSendSeconds.WithLabelValues(string(msg.Method)).Observe(time.Since(start).Seconds()) + n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Dec() if err != nil { // Don't try to accumulate message responses if the context has been canceled. @@ -281,7 +279,7 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification } func (n *notifier) newSuccessfulDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult { - n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), ResultSuccess).Inc() + n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), ResultSuccess).Inc() return dispatchResult{ notifier: n.id, @@ -301,7 +299,7 @@ func (n *notifier) newFailedDispatch(msg database.AcquireNotificationMessagesRow result = ResultPermFail } - n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), result).Inc() + n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), result).Inc() return dispatchResult{ notifier: n.id, From 4ba160dcdb22c9f80ff9875a529ea1456930d6be Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 10:56:12 +0200 Subject: [PATCH 12/17] Fix swagger docs Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 15 ++++++++++++--- coderd/apidoc/swagger.json | 15 ++++++++++++--- docs/api/enterprise.md | 10 ++++++++-- enterprise/coderd/notifications.go | 7 ++++--- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f16882df51b4b..5fbb14a467c7b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1654,13 +1654,22 @@ const docTemplate = `{ "Enterprise" ], "summary": "Update notification template dispatch method", - "operationId": "put-notification-template-method", + "operationId": "update-notification-template-dispatch-method", + "parameters": [ + { + "type": "string", + "description": "Notification template UUID", + "name": "notification_template", + "in": "path", + "required": true + } + ], "responses": { "200": { - "description": "OK" + "description": "Success" }, "304": { - "description": "Not Modified" + "description": "Not modified" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2fead38f11efd..4da50c5261177 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1433,13 +1433,22 @@ "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Update notification template dispatch method", - "operationId": "put-notification-template-method", + "operationId": "update-notification-template-dispatch-method", + "parameters": [ + { + "type": "string", + "description": "Notification template UUID", + "name": "notification_template", + "in": "path", + "required": true + } + ], "responses": { "200": { - "description": "OK" + "description": "Success" }, "304": { - "description": "Not Modified" + "description": "Not modified" } } } diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index b25ad74409130..be30b790d4aef 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -549,12 +549,18 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notificatio `PUT /notifications/templates/{notification_template}/method` +### Parameters + +| Name | In | Type | Required | Description | +| ----------------------- | ---- | ------ | -------- | -------------------------- | +| `notification_template` | path | string | true | Notification template UUID | + ### Responses | Status | Meaning | Description | Schema | | ------ | --------------------------------------------------------------- | ------------ | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | -| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Success | | +| 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). diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go index 811fa82e29f37..3f3ea2b911026 100644 --- a/enterprise/coderd/notifications.go +++ b/enterprise/coderd/notifications.go @@ -15,12 +15,13 @@ import ( ) // @Summary Update notification template dispatch method -// @ID put-notification-template-method +// @ID update-notification-template-dispatch-method // @Security CoderSessionToken // @Produce json +// @Param notification_template path string true "Notification template UUID" // @Tags Enterprise -// @Success 200 -// @Success 304 +// @Success 200 "Success" +// @Success 304 "Not modified" // @Router /notifications/templates/{notification_template}/method [put] func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) { var ( From 061b2bfb99f2e95b4c3c85fe3f59141b7634e245 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 11:01:23 +0200 Subject: [PATCH 13/17] make lint/fmt Signed-off-by: Danny Kopping --- coderd/notifications/notifications_test.go | 11 ++++++----- coderd/notifications/notifier.go | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 0f3312f66c386..6a61655c84c84 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -748,12 +748,12 @@ func TestCustomNotificationMethod(t *testing.T) { // GIVEN: a notification template which has a method explicitly set var ( - template = notifications.TemplateWorkspaceDormant + template = notifications.TemplateWorkspaceDormant defaultMethod = database.NotificationMethodSmtp - customMethod = database.NotificationMethodWebhook + customMethod = database.NotificationMethodWebhook ) out, err := db.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{ - ID: template, + ID: template, Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true}, }) require.NoError(t, err) @@ -762,8 +762,8 @@ func TestCustomNotificationMethod(t *testing.T) { // GIVEN: a manager configured with multiple dispatch methods cfg := defaultNotificationsConfig(defaultMethod) cfg.SMTP = codersdk.NotificationsEmailConfig{ - From: "danny@coder.com", - Hello: "localhost", + From: "danny@coder.com", + Hello: "localhost", Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())}, } cfg.Webhook = codersdk.NotificationsWebhookConfig{ @@ -782,6 +782,7 @@ func TestCustomNotificationMethod(t *testing.T) { // WHEN: a notification of that template is enqueued, it should be delivered with the configured method - not the default. user := createSampleUser(t, db) msgID, err := enq.Enqueue(ctx, user.ID, template, map[string]string{}, "test") + require.NoError(t, err) // THEN: the notification should be received by the custom dispatch method mgr.Run(ctx) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index df10c4e08e259..62e92f8ace4b8 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -33,21 +33,21 @@ type notifier struct { quit chan any done chan any - handlers map[database.NotificationMethod]Handler + handlers map[database.NotificationMethod]Handler metrics *Metrics } func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, metrics *Metrics) *notifier { return ¬ifier{ - id: id, - cfg: cfg, - log: log.Named("notifier").With(slog.F("notifier_id", id)), - quit: make(chan any), - done: make(chan any), - tick: time.NewTicker(cfg.FetchInterval.Value()), - store: db, - handlers: hr, - metrics: metrics, + id: id, + cfg: cfg, + log: log.Named("notifier").With(slog.F("notifier_id", id)), + quit: make(chan any), + done: make(chan any), + tick: time.NewTicker(cfg.FetchInterval.Value()), + store: db, + handlers: hr, + metrics: metrics, } } From 8a6e289cae796c0fc8f3f606db901c1cc426c225 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 11:08:11 +0200 Subject: [PATCH 14/17] Fix tests Signed-off-by: Danny Kopping --- cli/notifications_test.go | 16 +++++++++++++--- coderd/notifications/notifications_test.go | 5 +++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 9ea4d7072e4c3..9d7ff8a37abc3 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -16,6 +16,16 @@ 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 TestNotifications(t *testing.T) { t.Parallel() @@ -42,7 +52,7 @@ func TestNotifications(t *testing.T) { t.Parallel() // given - ownerClient, db := coderdtest.NewWithDatabase(t, nil) + ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t)) _ = coderdtest.CreateFirstUser(t, ownerClient) // when @@ -72,7 +82,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) { t.Parallel() // given - ownerClient, db := coderdtest.NewWithDatabase(t, nil) + ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t)) owner := coderdtest.CreateFirstUser(t, ownerClient) anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) @@ -87,7 +97,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) { require.Error(t, err) require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") assert.Equal(t, http.StatusForbidden, sdkError.StatusCode()) - assert.Contains(t, sdkError.Message, "Insufficient permissions to update notifications settings.") + assert.Contains(t, sdkError.Message, "Forbidden.") // then ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 6a61655c84c84..31f30d864dedd 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -799,8 +799,9 @@ func TestCustomNotificationMethod(t *testing.T) { require.NoError(t, err) require.EventuallyWithT(t, func(ct *assert.CollectT) { msgs := mockSMTPSrv.MessagesAndPurge() - assert.Len(ct, msgs, 1) - assert.Contains(ct, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID)) + if assert.Len(ct, msgs, 1) { + assert.Contains(ct, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID)) + } }, testutil.WaitLong, testutil.IntervalFast) } From d627fef07671ce6017e8e5c3d7dd7699a3511a53 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 09:49:26 +0200 Subject: [PATCH 15/17] Test metric collection when dispatching with custom methods Signed-off-by: Danny Kopping --- coderd/notifications/metrics_test.go | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go index 6c360dd2919d0..139f7ae18c6c6 100644 --- a/coderd/notifications/metrics_test.go +++ b/coderd/notifications/metrics_test.go @@ -339,6 +339,81 @@ func TestInflightDispatchesMetric(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } +func TestCustomMethodMetricCollection(t *testing.T) { + t.Parallel() + + // SETUP + if !dbtestutil.WillUsePostgres() { + // UpdateNotificationTemplateMethodByID only makes sense with a real database. + t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") + } + ctx, logger, store := setup(t) + + var ( + reg = prometheus.NewRegistry() + metrics = notifications.NewMetrics(reg) + template = notifications.TemplateWorkspaceDeleted + anotherTemplate = notifications.TemplateWorkspaceDormant + ) + + const ( + customMethod = database.NotificationMethodWebhook + defaultMethod = database.NotificationMethodSmtp + ) + + // GIVEN: a template whose notification method differs from the default. + out, err := store.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{ + ID: template, + Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, customMethod, out.Method.NotificationMethod) + + // WHEN: two notifications (each with different templates) are enqueued. + cfg := defaultNotificationsConfig(defaultMethod) + mgr, err := notifications.NewManager(cfg, store, metrics, logger.Named("manager")) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + + smtpHandler := &fakeHandler{} + webhookHandler := &fakeHandler{} + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ + defaultMethod: smtpHandler, + customMethod: webhookHandler, + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + + user := createSampleUser(t, store) + + _, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + _, err = enq.Enqueue(ctx, user.ID, anotherTemplate, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + + mgr.Run(ctx) + + // THEN: the fake handlers to "dispatch" the notifications. + require.Eventually(t, func() bool { + smtpHandler.mu.RLock() + webhookHandler.mu.RLock() + defer smtpHandler.mu.RUnlock() + defer webhookHandler.mu.RUnlock() + + return len(smtpHandler.succeeded) == 1 && len(smtpHandler.failed) == 0 && + len(webhookHandler.succeeded) == 1 && len(webhookHandler.failed) == 0 + }, testutil.WaitShort, testutil.IntervalFast) + + // THEN: we should have metric series for both the default and custom notification methods. + require.Eventually(t, func() bool { + return promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(defaultMethod), anotherTemplate.String(), notifications.ResultSuccess)) > 0 && + promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(customMethod), template.String(), notifications.ResultSuccess)) > 0 + }, testutil.WaitShort, testutil.IntervalFast) +} + // hasMatchingFingerprint checks if the given metric's series fingerprint matches the reference fingerprint. func hasMatchingFingerprint(metric *dto.Metric, fp model.Fingerprint) bool { return fingerprintLabelPairs(metric.Label) == fp From d1817d1a7c6025d73a6fbf61556a323dcc7e2b09 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 15:06:54 +0200 Subject: [PATCH 16/17] Return early Signed-off-by: Danny Kopping --- coderd/notifications.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index 0545f19ec2026..10f84e4d82d96 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -105,12 +105,12 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request if err != nil { if rbac.IsUnauthorizedError(err) { httpapi.Forbidden(rw) - } else { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update notifications settings.", - Detail: err.Error(), - }) + return } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications settings.", + Detail: err.Error(), + }) return } From 33e9bc92c68b5b9dd882203ce9b7ca722b19f905 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 Aug 2024 15:16:23 +0200 Subject: [PATCH 17/17] feat: add notification preferences business logic & APIs (#14117) --- coderd/apidoc/docs.go | 153 +++++++++++++++ coderd/apidoc/swagger.json | 139 +++++++++++++ coderd/coderd.go | 7 + coderd/notifications.go | 139 +++++++++++++ coderd/notifications/enqueuer.go | 12 ++ coderd/notifications/manager.go | 16 +- coderd/notifications/notifications_test.go | 88 +++++++++ coderd/notifications/notifier.go | 21 +- coderd/notifications_test.go | 215 +++++++++++++++++++++ codersdk/notifications.go | 91 +++++++++ docs/api/notifications.md | 161 +++++++++++++++ docs/api/schemas.md | 52 +++++ site/src/api/typesGenerated.ts | 18 ++ 13 files changed, 1107 insertions(+), 5 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5fbb14a467c7b..962fccae0a4ea 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1547,6 +1547,34 @@ const docTemplate = `{ } } }, + "/notifications/dispatch-methods": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification dispatch methods", + "operationId": "get-notification-dispatch-methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationMethodsResponse" + } + } + } + } + } + }, "/notifications/settings": { "get": { "security": [ @@ -5416,6 +5444,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": [ @@ -10264,6 +10376,36 @@ const docTemplate = `{ } } }, + "codersdk.NotificationMethodsResponse": { + "type": "object", + "properties": { + "available": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + } + } + }, + "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": { @@ -12609,6 +12751,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 4da50c5261177..35b8b82a21888 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1344,6 +1344,30 @@ } } }, + "/notifications/dispatch-methods": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get notification dispatch methods", + "operationId": "get-notification-dispatch-methods", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationMethodsResponse" + } + } + } + } + } + }, "/notifications/settings": { "get": { "security": [ @@ -4780,6 +4804,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": [ @@ -9197,6 +9295,36 @@ } } }, + "codersdk.NotificationMethodsResponse": { + "type": "object", + "properties": { + "available": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + } + } + }, + "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": { @@ -11450,6 +11578,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 b45e5219a74d0..7fbfe7d477f06 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,6 +1258,7 @@ func New(options *Options) *API { r.Route("/templates", func(r chi.Router) { r.Get("/system", api.systemNotificationTemplates) }) + r.Get("/dispatch-methods", api.notificationDispatchMethods) }) }) diff --git a/coderd/notifications.go b/coderd/notifications.go index 10f84e4d82d96..bdf71f99cab98 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,6 +144,130 @@ func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Requ httpapi.Write(r.Context(), rw, http.StatusOK, out) } +// @Summary Get notification dispatch methods +// @ID get-notification-dispatch-methods +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationMethodsResponse +// @Router /notifications/dispatch-methods [get] +func (api *API) notificationDispatchMethods(rw http.ResponseWriter, r *http.Request) { + var methods []string + for _, nm := range database.AllNotificationMethodValues() { + methods = append(methods, string(nm)) + } + + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.NotificationMethodsResponse{ + AvailableNotificationMethods: methods, + DefaultNotificationMethod: api.DeploymentValues.Notifications.Method.Value(), + }) +} + +// @Summary Get user notification preferences +// @ID get-user-notification-preferences +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Param user path string true "User ID, name, or me" +// @Success 200 {array} codersdk.NotificationPreference +// @Router /users/{user}/notifications/preferences [get] +func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID)) + ) + + prefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID) + if err != nil { + logger.Error(ctx, "failed to retrieve preferences", slog.Error(err)) + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to retrieve user notification preferences.", + Detail: err.Error(), + }) + return + } + + out := convertNotificationPreferences(prefs) + httpapi.Write(ctx, rw, http.StatusOK, out) +} + +// @Summary Update user notification preferences +// @ID update-user-notification-preferences +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Notifications +// @Param request body codersdk.UpdateUserNotificationPreferences true "Preferences" +// @Param user path string true "User ID, name, or me" +// @Success 200 {array} codersdk.NotificationPreference +// @Router /users/{user}/notifications/preferences [put] +func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID)) + ) + + // Parse request. + var prefs codersdk.UpdateUserNotificationPreferences + if !httpapi.Read(ctx, rw, r, &prefs) { + return + } + + // Build query params. + 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), slog.Error(err)) + + httpapi.Write(ctx, 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) + } + + // Update preferences with params. + updated, err := api.Database.UpdateUserNotificationPreferences(ctx, input) + if err != nil { + logger.Error(ctx, "failed to update preferences", slog.Error(err)) + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update user notifications preferences.", + Detail: err.Error(), + }) + return + } + + // Preferences updated, now fetch all preferences belonging to this user. + logger.Info(ctx, "updated preferences", slog.F("count", updated)) + + userPrefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID) + if err != nil { + logger.Error(ctx, "failed to retrieve preferences", slog.Error(err)) + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to retrieve user notifications preferences.", + Detail: err.Error(), + }) + return + } + + out := convertNotificationPreferences(userPrefs) + httpapi.Write(ctx, rw, http.StatusOK, out) +} + func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { for _, tmpl := range in { out = append(out, codersdk.NotificationTemplate{ @@ -157,3 +284,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/enqueuer.go b/coderd/notifications/enqueuer.go index 3585f28007e67..d990a71bdb5ad 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 @@ -80,6 +83,15 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI CreatedBy: createdBy, }) if err != nil { + // We have a trigger on the notification_messages table named `inhibit_enqueue_if_disabled` which prevents messages + // from being enqueued if the user has disabled them via notification_preferences. The trigger will fail the insertion + // with the message "cannot enqueue message: user has disabled this notification". + // + // This is more efficient than fetching the user's preferences for each enqueue, and centralizes the business logic. + 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 51daa9fc4742c..91580d5fc4fb7 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 31f30d864dedd..d73edbf7c453b 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -705,6 +705,94 @@ func TestNotificationTemplatesBody(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") +} + func TestCustomNotificationMethod(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 62e92f8ace4b8..ac7ed8b2d5f4a 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications/dispatch" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" @@ -142,6 +143,12 @@ 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 { @@ -284,7 +291,7 @@ func (n *notifier) newSuccessfulDispatch(msg database.AcquireNotificationMessage return dispatchResult{ notifier: n.id, msg: msg.ID, - ts: time.Now(), + ts: dbtime.Now(), } } @@ -304,12 +311,22 @@ func (n *notifier) newFailedDispatch(msg database.AcquireNotificationMessagesRow return dispatchResult{ notifier: n.id, msg: msg.ID, - ts: time.Now(), + ts: dbtime.Now(), err: err, retryable: retryable, } } +func (n *notifier) newInhibitedDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult { + return dispatchResult{ + notifier: n.id, + msg: msg.ID, + ts: dbtime.Now(), + retryable: false, + inhibited: true, + } +} + // stop stops the notifier from processing any new notifications. // This is a graceful stop, so any in-flight notifications will be completed before the notifier stops. // Once a notifier has stopped, it cannot be restarted. diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 19bc3044f8d68..537e8cef095d1 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -5,8 +5,13 @@ import ( "testing" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + + "github.com/coder/serpent" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -103,3 +108,213 @@ func TestUpdateNotificationsSettings(t *testing.T) { require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) }) } + +func TestNotificationPreferences(t *testing.T) { + t.Parallel() + + t.Run("Initial state", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member in its initial state. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: calling the API. + prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + + // Then: no preferences will be returned. + require.Len(t, prefs, 0) + }) + + t.Run("Insufficient permissions", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: 2 members. + _, member1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + member2Client, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to retrieve the preferences of another member. + _, err := member2Client.GetUserNotificationPreferences(ctx, member1.ID) + + // Then: the API should reject the request. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + // NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden. + // This is not ideal, and we should probably change this behavior. + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + }) + + t.Run("Admin may read any users' preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member. + _, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to retrieve the preferences of another member as an admin. + prefs, err := api.GetUserNotificationPreferences(ctx, member.ID) + + // Then: the API should not reject the request. + require.NoError(t, err) + require.Len(t, prefs, 0) + }) + + t.Run("Admin may update any users' preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to modify and subsequently retrieve the preferences of another member as an admin. + prefs, err := api.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceMarkedForDeletion.String(): true, + }, + }) + + // Then: the request should succeed and the user should be able to query their own preferences to see the same result. + require.NoError(t, err) + require.Len(t, prefs, 1) + + memberPrefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + require.Len(t, memberPrefs, 1) + require.Equal(t, prefs[0].NotificationTemplateID, memberPrefs[0].NotificationTemplateID) + require.Equal(t, prefs[0].Disabled, memberPrefs[0].Disabled) + }) + + t.Run("Add preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member with no preferences. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID) + require.NoError(t, err) + require.Len(t, prefs, 0) + + // When: attempting to add new preferences. + template := notifications.TemplateWorkspaceDeleted + prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + template.String(): true, + }, + }) + + // Then: the returning preferences should be set as expected. + require.NoError(t, err) + require.Len(t, prefs, 1) + require.Equal(t, prefs[0].NotificationTemplateID, template) + require.True(t, prefs[0].Disabled) + }) + + t.Run("Modify preferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, createOpts(t)) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Given: a member with preferences. + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + prefs, err := memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceDeleted.String(): true, + notifications.TemplateWorkspaceDormant.String(): true, + }, + }) + require.NoError(t, err) + require.Len(t, prefs, 2) + + // When: attempting to modify their preferences. + prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{ + TemplateDisabledMap: map[string]bool{ + notifications.TemplateWorkspaceDeleted.String(): true, + notifications.TemplateWorkspaceDormant.String(): false, // <--- this one was changed + }, + }) + require.NoError(t, err) + require.Len(t, prefs, 2) + + // Then: the modified preferences should be set as expected. + var found bool + for _, p := range prefs { + switch p.NotificationTemplateID { + case notifications.TemplateWorkspaceDormant: + found = true + require.False(t, p.Disabled) + case notifications.TemplateWorkspaceDeleted: + require.True(t, p.Disabled) + } + } + require.True(t, found, "dormant notification preference was not found") + }) +} + +func TestNotificationDispatchMethods(t *testing.T) { + t.Parallel() + + defaultOpts := createOpts(t) + webhookOpts := createOpts(t) + webhookOpts.DeploymentValues.Notifications.Method = serpent.String(database.NotificationMethodWebhook) + + tests := []struct { + name string + opts *coderdtest.Options + expectedDefault string + }{ + { + name: "default", + opts: defaultOpts, + expectedDefault: string(database.NotificationMethodSmtp), + }, + { + name: "non-default", + opts: webhookOpts, + expectedDefault: string(database.NotificationMethodWebhook), + }, + } + + var allMethods []string + for _, nm := range database.AllNotificationMethodValues() { + allMethods = append(allMethods, string(nm)) + } + slices.Sort(allMethods) + + // nolint:paralleltest // Not since Go v1.22. + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + api := coderdtest.New(t, tc.opts) + _ = coderdtest.CreateFirstUser(t, api) + + resp, err := api.GetNotificationDispatchMethods(ctx) + require.NoError(t, err) + + slices.Sort(resp.AvailableNotificationMethods) + require.EqualValues(t, resp.AvailableNotificationMethods, allMethods) + require.Equal(t, tc.expectedDefault, resp.DefaultNotificationMethod) + }) + } +} diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 49cf589ba895c..92870b4dd2b95 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,17 @@ type NotificationTemplate struct { Kind string `json:"kind"` } +type NotificationMethodsResponse struct { + AvailableNotificationMethods []string `json:"available"` + DefaultNotificationMethod string `json:"default"` +} + +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 +117,85 @@ func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]Notifica return templates, nil } +// GetUserNotificationPreferences retrieves notification preferences for a given user. +func (c *Client) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var prefs []NotificationPreference + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &prefs); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return prefs, nil +} + +// UpdateUserNotificationPreferences updates notification preferences for a given user. +func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, userID uuid.UUID, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var prefs []NotificationPreference + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &prefs); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return prefs, nil +} + +// GetNotificationDispatchMethods the available and default notification dispatch methods. +func (c *Client) GetNotificationDispatchMethods(ctx context.Context) (NotificationMethodsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/dispatch-methods", nil) + if err != nil { + return NotificationMethodsResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return NotificationMethodsResponse{}, ReadBodyAsError(res) + } + + var resp NotificationMethodsResponse + body, err := io.ReadAll(res.Body) + if err != nil { + return NotificationMethodsResponse{}, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &resp); err != nil { + return NotificationMethodsResponse{}, xerrors.Errorf("unmarshal response body: %w", err) + } + + return resp, nil +} + type UpdateNotificationTemplateMethod struct { Method string `json:"method,omitempty" example:"webhook"` } + +type UpdateUserNotificationPreferences struct { + TemplateDisabledMap map[string]bool `json:"template_disabled_map"` +} diff --git a/docs/api/notifications.md b/docs/api/notifications.md index ca43565a982a9..528153ebd103b 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -1,5 +1,49 @@ # Notifications +## Get notification dispatch methods + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/dispatch-methods \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/dispatch-methods` + +### Example responses + +> 200 Response + +```json +[ + { + "available": ["string"], + "default": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationMethodsResponse](schemas.md#codersdknotificationmethodsresponse) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» available` | array | false | | | +| `» default` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get notifications settings ### Code samples @@ -133,3 +177,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 7e28faa047a8f..7406d135112f1 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3141,6 +3141,40 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `id` | string | true | | | | `username` | string | true | | | +## codersdk.NotificationMethodsResponse + +```json +{ + "available": ["string"], + "default": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------- | --------------- | -------- | ------------ | ----------- | +| `available` | array of string | false | | | +| `default` | string | false | | | + +## 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 @@ -5565,6 +5599,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 5e49dc1698246..5c2dc816fea1e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -709,6 +709,19 @@ export interface MinimalUser { readonly avatar_url: string; } +// From codersdk/notifications.go +export interface NotificationMethodsResponse { + readonly available: readonly string[]; + readonly default: 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; @@ -1512,6 +1525,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;