diff --git a/coderd/database/migrations/000270_template_deprecation_notification.down.sql b/coderd/database/migrations/000270_template_deprecation_notification.down.sql new file mode 100644 index 0000000000000..b3f9abc0133bd --- /dev/null +++ b/coderd/database/migrations/000270_template_deprecation_notification.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'f40fae84-55a2-42cd-99fa-b41c1ca64894'; diff --git a/coderd/database/migrations/000270_template_deprecation_notification.up.sql b/coderd/database/migrations/000270_template_deprecation_notification.up.sql new file mode 100644 index 0000000000000..e98f852c8b4e1 --- /dev/null +++ b/coderd/database/migrations/000270_template_deprecation_notification.up.sql @@ -0,0 +1,22 @@ +INSERT INTO notification_templates + (id, name, title_template, body_template, "group", actions) +VALUES ( + 'f40fae84-55a2-42cd-99fa-b41c1ca64894', + 'Template Deprecated', + E'Template ''{{.Labels.template}}'' has been deprecated', + E'Hello {{.UserName}},\n\n'|| + E'The template **{{.Labels.template}}** has been deprecated with the following message:\n\n' || + E'**{{.Labels.message}}**\n\n' || + E'New workspaces may not be created from this template. Existing workspaces will continue to function normally.', + 'Template Events', + '[ + { + "label": "See affected workspaces", + "url": "{{base_url}}/workspaces?filter=owner%3Ame+template%3A{{.Labels.template}}" + }, + { + "label": "View template", + "url": "{{base_url}}/templates/{{.Labels.organization}}/{{.Labels.template}}" + } + ]'::jsonb +); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index c2e0f442e0623..e33a85b523db2 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -30,7 +30,8 @@ var ( // Template-related events. var ( - TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") + TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") + TemplateTemplateDeprecated = uuid.MustParse("f40fae84-55a2-42cd-99fa-b41c1ca64894") TemplateWorkspaceBuildsFailedReport = uuid.MustParse("34a20db2-e9cc-4a93-b0e4-8569699d7a00") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 4a6978b5024fe..86ed14fe90957 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1021,6 +1021,20 @@ func TestNotificationTemplates_Golden(t *testing.T) { appName: "Custom Application Name", logoURL: "https://custom.application/logo.png", }, + { + name: "TemplateTemplateDeprecated", + id: notifications.TemplateTemplateDeprecated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "template": "alpha", + "message": "This template has been replaced by beta", + "organization": "coder", + }, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden new file mode 100644 index 0000000000000..1393acc4bc60a --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -0,0 +1,98 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Template 'alpha' has been deprecated +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hello Bobby, + +The template alpha has been deprecated with the following message: + +This template has been replaced by beta + +New workspaces may not be created from this template. Existing workspaces w= +ill continue to function normally. + + +See affected workspaces: http://test.com/workspaces?filter=3Downer%3Ame+tem= +plate%3Aalpha + +View template: http://test.com/templates/coder/alpha + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ Template 'alpha' has been deprecated +

+
+

Hello Bobby,

+ +

The template alpha has been deprecated with the followi= +ng message:

+ +

This template has been replaced by beta

+ +

New workspaces may not be created from this template. Existing workspace= +s will continue to function normally.

+
+
+ =20 + + See affected workspaces + + =20 + + View template + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden new file mode 100644 index 0000000000000..c4202271c5257 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden @@ -0,0 +1,33 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.1", + "notification_name": "Template Deprecated", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "See affected workspaces", + "url": "http://test.com/workspaces?filter=owner%3Ame+template%3Aalpha" + }, + { + "label": "View template", + "url": "http://test.com/templates/coder/alpha" + } + ], + "labels": { + "message": "This template has been replaced by beta", + "organization": "coder", + "template": "alpha" + }, + "data": null + }, + "title": "Template 'alpha' has been deprecated", + "title_markdown": "Template 'alpha' has been deprecated", + "body": "Hello Bobby,\n\nThe template alpha has been deprecated with the following message:\n\nThis template has been replaced by beta\n\nNew workspaces may not be created from this template. Existing workspaces will continue to function normally.", + "body_markdown": "Hello Bobby,\n\nThe template **alpha** has been deprecated with the following message:\n\n**This template has been replaced by beta**\n\nNew workspaces may not be created from this template. Existing workspaces will continue to function normally." +} \ No newline at end of file diff --git a/coderd/templates.go b/coderd/templates.go index 907a4d1265836..cbc6eb784d2e4 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -845,6 +845,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return } + if template.Deprecated != updated.Deprecated && updated.Deprecated != "" { + if err := api.notifyUsersOfTemplateDeprecation(ctx, updated); err != nil { + api.Logger.Error(ctx, "failed to notify users of template deprecation", slog.Error(err)) + } + } + if updated.UpdatedAt.IsZero() { aReq.New = template rw.WriteHeader(http.StatusNotModified) @@ -855,6 +861,42 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(updated)) } +func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template database.Template) error { + workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + TemplateIDs: []uuid.UUID{template.ID}, + }) + if err != nil { + return xerrors.Errorf("get workspaces by template id: %w", err) + } + + users := make(map[uuid.UUID]struct{}) + for _, workspace := range workspaces { + users[workspace.OwnerID] = struct{}{} + } + + errs := []error{} + + for userID := range users { + _, err = api.NotificationsEnqueuer.Enqueue( + //nolint:gocritic // We need the system auth context to be able to send the deprecation notification. + dbauthz.AsSystemRestricted(ctx), + userID, + notifications.TemplateTemplateDeprecated, + map[string]string{ + "template": template.Name, + "message": template.Deprecated, + "organization": template.OrganizationName, + }, + "notify-users-of-template-deprecation", + ) + if err != nil { + errs = append(errs, xerrors.Errorf("enqueue notification: %w", err)) + } + } + + return errors.Join(errs...) +} + // @Summary Get template DAUs by ID // @ID get-template-daus-by-id // @Security CoderSessionToken diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 5d9cb8ee9fa35..cde01553e349c 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "net/http" + "slices" "testing" "time" @@ -38,9 +39,11 @@ func TestTemplates(t *testing.T) { t.Run("Deprecated", func(t *testing.T) { t.Parallel() + notifyEnq := &testutil.FakeNotificationsEnqueuer{} owner, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -48,11 +51,24 @@ func TestTemplates(t *testing.T) { }, }, }) - client, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + client, secondUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + otherClient, otherUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + _ = coderdtest.CreateWorkspace(t, owner, template.ID) + _ = coderdtest.CreateWorkspace(t, client, template.ID) + + // Create another template for testing that users of another template do not + // get a notification. + secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + secondTemplate := coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, secondVersion.ID) + + _ = coderdtest.CreateWorkspace(t, otherClient, secondTemplate.ID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -65,6 +81,32 @@ func TestTemplates(t *testing.T) { assert.True(t, updated.Deprecated) assert.NotEmpty(t, updated.DeprecationMessage) + notifs := []*testutil.Notification{} + for _, notif := range notifyEnq.Sent { + if notif.TemplateID == notifications.TemplateTemplateDeprecated { + notifs = append(notifs, notif) + } + } + require.Equal(t, 2, len(notifs)) + + expectedSentTo := []string{user.UserID.String(), secondUser.ID.String()} + slices.Sort(expectedSentTo) + + sentTo := []string{} + for _, notif := range notifs { + sentTo = append(sentTo, notif.UserID.String()) + } + slices.Sort(sentTo) + + // Require the notification to have only been sent to the expected users + assert.Equal(t, expectedSentTo, sentTo) + + // The previous check should verify this but we're double checking that + // the notification wasn't sent to users not using the template. + for _, notif := range notifs { + assert.NotEqual(t, otherUser.ID, notif.UserID) + } + _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, Name: "foobar",