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
+
+
+
+
+

+
+
+ 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.
+
+
+
+
+
+
+
+--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",