Skip to content

Commit 6f1951e

Browse files
feat: add template delete notification (#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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy