Skip to content

Commit 6f1951e

Browse files
feat: add template delete notification (coder#14250)
1 parent 86b9c97 commit 6f1951e

File tree

7 files changed

+195
-1
lines changed

7 files changed

+195
-1
lines changed

coderd/database/migrations/000244_notifications_delete_template.down.sql

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
INSERT INTO
2+
notification_templates (
3+
id,
4+
name,
5+
title_template,
6+
body_template,
7+
"group",
8+
actions
9+
)
10+
VALUES (
11+
'29a09665-2a4c-403f-9648-54301670e7be',
12+
'Template Deleted',
13+
E'Template "{{.Labels.name}}" deleted',
14+
E'Hi {{.UserName}}\n\nThe template **{{.Labels.name}}** was deleted by **{{ .Labels.initiator }}**.',
15+
'Template Events',
16+
'[
17+
{
18+
"label": "View templates",
19+
"url": "{{ base_url }}/templates"
20+
}
21+
]'::jsonb
22+
);

coderd/notifications/events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ var (
1919
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
2020
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")
2121
)
22+
23+
// Template-related events.
24+
var (
25+
TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be")
26+
)

coderd/notifications/notifications_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,17 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
740740
},
741741
},
742742
},
743+
{
744+
name: "TemplateTemplateDeleted",
745+
id: notifications.TemplateTemplateDeleted,
746+
payload: types.MessagePayload{
747+
UserName: "bobby",
748+
Labels: map[string]string{
749+
"name": "bobby-template",
750+
"initiator": "rob",
751+
},
752+
},
753+
},
743754
}
744755

745756
allTemplates, err := enumerateAllTemplates(t)

coderd/templates.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd
22

33
import (
4+
"context"
45
"database/sql"
56
"errors"
67
"fmt"
@@ -12,12 +13,15 @@ import (
1213
"github.com/google/uuid"
1314
"golang.org/x/xerrors"
1415

16+
"cdr.dev/slog"
17+
1518
"github.com/coder/coder/v2/coderd/audit"
1619
"github.com/coder/coder/v2/coderd/database"
1720
"github.com/coder/coder/v2/coderd/database/dbauthz"
1821
"github.com/coder/coder/v2/coderd/database/dbtime"
1922
"github.com/coder/coder/v2/coderd/httpapi"
2023
"github.com/coder/coder/v2/coderd/httpmw"
24+
"github.com/coder/coder/v2/coderd/notifications"
2125
"github.com/coder/coder/v2/coderd/rbac"
2226
"github.com/coder/coder/v2/coderd/rbac/policy"
2327
"github.com/coder/coder/v2/coderd/schedule"
@@ -56,6 +60,7 @@ func (api *API) template(rw http.ResponseWriter, r *http.Request) {
5660
// @Router /templates/{template} [delete]
5761
func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
5862
var (
63+
apiKey = httpmw.APIKey(r)
5964
ctx = r.Context()
6065
template = httpmw.TemplateParam(r)
6166
auditor = *api.Auditor.Load()
@@ -101,11 +106,47 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
101106
})
102107
return
103108
}
109+
110+
admins, err := findTemplateAdmins(ctx, api.Database)
111+
if err != nil {
112+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
113+
Message: "Internal error fetching template admins.",
114+
Detail: err.Error(),
115+
})
116+
return
117+
}
118+
for _, admin := range admins {
119+
// Don't send notification to user which initiated the event.
120+
if admin.ID == apiKey.UserID {
121+
continue
122+
}
123+
api.notifyTemplateDeleted(ctx, template, apiKey.UserID, admin.ID)
124+
}
125+
104126
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
105127
Message: "Template has been deleted!",
106128
})
107129
}
108130

131+
func (api *API) notifyTemplateDeleted(ctx context.Context, template database.Template, initiatorID uuid.UUID, receiverID uuid.UUID) {
132+
initiator, err := api.Database.GetUserByID(ctx, initiatorID)
133+
if err != nil {
134+
api.Logger.Warn(ctx, "failed to fetch initiator for template deletion notification", slog.F("initiator_id", initiatorID), slog.Error(err))
135+
return
136+
}
137+
138+
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, receiverID, notifications.TemplateTemplateDeleted,
139+
map[string]string{
140+
"name": template.Name,
141+
"initiator": initiator.Username,
142+
}, "api-templates-delete",
143+
// Associate this notification with all the related entities.
144+
template.ID, template.OrganizationID,
145+
); err != nil {
146+
api.Logger.Warn(ctx, "failed to notify of template deletion", slog.F("deleted_template_id", template.ID), slog.Error(err))
147+
}
148+
}
149+
109150
// Create a new template in an organization.
110151
// Returns a single template.
111152
//
@@ -948,3 +989,22 @@ func (api *API) convertTemplate(
948989
MaxPortShareLevel: maxPortShareLevel,
949990
}
950991
}
992+
993+
// findTemplateAdmins fetches all users with template admin permission including owners.
994+
func findTemplateAdmins(ctx context.Context, store database.Store) ([]database.GetUsersRow, error) {
995+
// Notice: we can't scrape the user information in parallel as pq
996+
// fails with: unexpected describe rows response: 'D'
997+
owners, err := store.GetUsers(ctx, database.GetUsersParams{
998+
RbacRole: []string{codersdk.RoleOwner},
999+
})
1000+
if err != nil {
1001+
return nil, xerrors.Errorf("get owners: %w", err)
1002+
}
1003+
templateAdmins, err := store.GetUsers(ctx, database.GetUsersParams{
1004+
RbacRole: []string{codersdk.RoleTemplateAdmin},
1005+
})
1006+
if err != nil {
1007+
return nil, xerrors.Errorf("get template admins: %w", err)
1008+
}
1009+
return append(owners, templateAdmins...), nil
1010+
}

coderd/templates_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/coder/coder/v2/coderd/database"
2020
"github.com/coder/coder/v2/coderd/database/dbauthz"
2121
"github.com/coder/coder/v2/coderd/database/dbtime"
22+
"github.com/coder/coder/v2/coderd/notifications"
2223
"github.com/coder/coder/v2/coderd/rbac"
2324
"github.com/coder/coder/v2/coderd/schedule"
2425
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -1326,3 +1327,98 @@ func TestTemplateMetrics(t *testing.T) {
13261327
dbtime.Now(), res.Workspaces[0].LastUsedAt, time.Minute,
13271328
)
13281329
}
1330+
1331+
func TestTemplateNotifications(t *testing.T) {
1332+
t.Parallel()
1333+
1334+
t.Run("Delete", func(t *testing.T) {
1335+
t.Parallel()
1336+
1337+
t.Run("InitiatorIsNotNotified", func(t *testing.T) {
1338+
t.Parallel()
1339+
1340+
// Given: an initiator
1341+
var (
1342+
notifyEnq = &testutil.FakeNotificationsEnqueuer{}
1343+
client = coderdtest.New(t, &coderdtest.Options{
1344+
IncludeProvisionerDaemon: true,
1345+
NotificationsEnqueuer: notifyEnq,
1346+
})
1347+
initiator = coderdtest.CreateFirstUser(t, client)
1348+
version = coderdtest.CreateTemplateVersion(t, client, initiator.OrganizationID, nil)
1349+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
1350+
template = coderdtest.CreateTemplate(t, client, initiator.OrganizationID, version.ID)
1351+
ctx = testutil.Context(t, testutil.WaitLong)
1352+
)
1353+
1354+
// When: the template is deleted by the initiator
1355+
err := client.DeleteTemplate(ctx, template.ID)
1356+
require.NoError(t, err)
1357+
1358+
// Then: the delete notification is not sent to the initiator.
1359+
deleteNotifications := make([]*testutil.Notification, 0)
1360+
for _, n := range notifyEnq.Sent {
1361+
if n.TemplateID == notifications.TemplateTemplateDeleted {
1362+
deleteNotifications = append(deleteNotifications, n)
1363+
}
1364+
}
1365+
require.Len(t, deleteNotifications, 0)
1366+
})
1367+
1368+
t.Run("OnlyOwnersAndAdminsAreNotified", func(t *testing.T) {
1369+
t.Parallel()
1370+
1371+
// Given: multiple users with different roles
1372+
var (
1373+
notifyEnq = &testutil.FakeNotificationsEnqueuer{}
1374+
client = coderdtest.New(t, &coderdtest.Options{
1375+
IncludeProvisionerDaemon: true,
1376+
NotificationsEnqueuer: notifyEnq,
1377+
})
1378+
initiator = coderdtest.CreateFirstUser(t, client)
1379+
ctx = testutil.Context(t, testutil.WaitLong)
1380+
1381+
// Setup template
1382+
version = coderdtest.CreateTemplateVersion(t, client, initiator.OrganizationID, nil)
1383+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
1384+
template = coderdtest.CreateTemplate(t, client, initiator.OrganizationID, version.ID)
1385+
)
1386+
1387+
// Setup users with different roles
1388+
_, owner := coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleOwner())
1389+
_, tmplAdmin := coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleTemplateAdmin())
1390+
coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleMember())
1391+
coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleUserAdmin())
1392+
coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleAuditor())
1393+
1394+
// When: the template is deleted by the initiator
1395+
err := client.DeleteTemplate(ctx, template.ID)
1396+
require.NoError(t, err)
1397+
1398+
// Then: only owners and template admins should receive the
1399+
// notification.
1400+
shouldBeNotified := []uuid.UUID{owner.ID, tmplAdmin.ID}
1401+
var deleteTemplateNotifications []*testutil.Notification
1402+
for _, n := range notifyEnq.Sent {
1403+
if n.TemplateID == notifications.TemplateTemplateDeleted {
1404+
deleteTemplateNotifications = append(deleteTemplateNotifications, n)
1405+
}
1406+
}
1407+
notifiedUsers := make([]uuid.UUID, 0, len(deleteTemplateNotifications))
1408+
for _, n := range deleteTemplateNotifications {
1409+
notifiedUsers = append(notifiedUsers, n.UserID)
1410+
}
1411+
require.ElementsMatch(t, shouldBeNotified, notifiedUsers)
1412+
1413+
// Validate the notification content
1414+
for _, n := range deleteTemplateNotifications {
1415+
require.Equal(t, n.TemplateID, notifications.TemplateTemplateDeleted)
1416+
require.Contains(t, notifiedUsers, n.UserID)
1417+
require.Contains(t, n.Targets, template.ID)
1418+
require.Contains(t, n.Targets, template.OrganizationID)
1419+
require.Equal(t, n.Labels["name"], template.Name)
1420+
require.Equal(t, n.Labels["initiator"], coderdtest.FirstUserParams.Username)
1421+
}
1422+
})
1423+
})
1424+
}

coderd/workspaces_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3441,7 +3441,7 @@ func TestWorkspaceUsageTracking(t *testing.T) {
34413441
})
34423442
}
34433443

3444-
func TestNotifications(t *testing.T) {
3444+
func TestWorkspaceNotifications(t *testing.T) {
34453445
t.Parallel()
34463446

34473447
t.Run("Dormant", func(t *testing.T) {

0 commit comments

Comments
 (0)