Skip to content

Commit c818b4d

Browse files
authored
feat: add notification for suspended/activated account (coder#14367)
* migrations * notify * fix * TestNotifyUserSuspended * TestNotifyUserReactivate * post merge * fix escape * TestNotificationTemplatesCanRender * links and events * notifyEnq * findUserAdmins * notifyUserStatusChanged * go build * your and admin * tests * refactor * 247 * Danny's review
1 parent 046c1c4 commit c818b4d

File tree

6 files changed

+235
-3
lines changed

6 files changed

+235
-3
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
DELETE FROM notification_templates WHERE id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d';
2+
DELETE FROM notification_templates WHERE id = '6a2f0609-9b69-4d36-a989-9f5925b6cbff';
3+
DELETE FROM notification_templates WHERE id = '9f5af851-8408-4e73-a7a1-c6502ba46689';
4+
DELETE FROM notification_templates WHERE id = '1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
2+
VALUES ('b02ddd82-4733-4d02-a2d7-c36f3598997d', 'User account suspended', E'User account "{{.Labels.suspended_account_name}}" suspended',
3+
E'Hi {{.UserName}},\nUser account **{{.Labels.suspended_account_name}}** has been suspended.',
4+
'User Events', '[
5+
{
6+
"label": "View suspended accounts",
7+
"url": "{{ base_url }}/deployment/users?filter=status%3Asuspended"
8+
}
9+
]'::jsonb);
10+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
11+
VALUES ('6a2f0609-9b69-4d36-a989-9f5925b6cbff', 'Your account has been suspended', E'Your account "{{.Labels.suspended_account_name}}" has been suspended',
12+
E'Hi {{.UserName}},\nYour account **{{.Labels.suspended_account_name}}** has been suspended.',
13+
'User Events', '[]'::jsonb);
14+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
15+
VALUES ('9f5af851-8408-4e73-a7a1-c6502ba46689', 'User account activated', E'User account "{{.Labels.activated_account_name}}" activated',
16+
E'Hi {{.UserName}},\nUser account **{{.Labels.activated_account_name}}** has been activated.',
17+
'User Events', '[
18+
{
19+
"label": "View accounts",
20+
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
21+
}
22+
]'::jsonb);
23+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
24+
VALUES ('1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4', 'Your account has been activated', E'Your account "{{.Labels.activated_account_name}}" has been activated',
25+
E'Hi {{.UserName}},\nYour account **{{.Labels.activated_account_name}}** has been activated.',
26+
'User Events', '[
27+
{
28+
"label": "Open Coder",
29+
"url": "{{ base_url }}"
30+
}
31+
]'::jsonb);

coderd/notifications/events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ var (
1818
var (
1919
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
2020
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")
21+
22+
TemplateUserAccountSuspended = uuid.MustParse("b02ddd82-4733-4d02-a2d7-c36f3598997d")
23+
TemplateUserAccountActivated = uuid.MustParse("9f5af851-8408-4e73-a7a1-c6502ba46689")
24+
TemplateYourAccountSuspended = uuid.MustParse("6a2f0609-9b69-4d36-a989-9f5925b6cbff")
25+
TemplateYourAccountActivated = uuid.MustParse("1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4")
2126
)
2227

2328
// Template-related events.

coderd/notifications/notifications_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,46 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
756756
},
757757
},
758758
},
759+
{
760+
name: "TemplateUserAccountSuspended",
761+
id: notifications.TemplateUserAccountSuspended,
762+
payload: types.MessagePayload{
763+
UserName: "bobby",
764+
Labels: map[string]string{
765+
"suspended_account_name": "bobby",
766+
},
767+
},
768+
},
769+
{
770+
name: "TemplateUserAccountActivated",
771+
id: notifications.TemplateUserAccountActivated,
772+
payload: types.MessagePayload{
773+
UserName: "bobby",
774+
Labels: map[string]string{
775+
"activated_account_name": "bobby",
776+
},
777+
},
778+
},
779+
{
780+
name: "TemplateYourAccountSuspended",
781+
id: notifications.TemplateYourAccountSuspended,
782+
payload: types.MessagePayload{
783+
UserName: "bobby",
784+
Labels: map[string]string{
785+
"suspended_account_name": "bobby",
786+
},
787+
},
788+
},
789+
{
790+
name: "TemplateYourAccountActivated",
791+
id: notifications.TemplateYourAccountActivated,
792+
payload: types.MessagePayload{
793+
UserName: "bobby",
794+
Labels: map[string]string{
795+
"activated_account_name": "bobby",
796+
},
797+
},
798+
},
759799
{
760800
name: "TemplateTemplateDeleted",
761801
id: notifications.TemplateTemplateDeleted,

coderd/users.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
845845
}
846846
}
847847

848-
suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
848+
targetUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
849849
ID: user.ID,
850850
Status: status,
851851
UpdatedAt: dbtime.Now(),
@@ -857,7 +857,12 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
857857
})
858858
return
859859
}
860-
aReq.New = suspendedUser
860+
aReq.New = targetUser
861+
862+
err = api.notifyUserStatusChanged(ctx, user, status)
863+
if err != nil {
864+
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
865+
}
861866

862867
organizations, err := userOrganizationIDs(ctx, api, user)
863868
if err != nil {
@@ -867,9 +872,52 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
867872
})
868873
return
869874
}
875+
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations))
876+
}
877+
}
878+
879+
func (api *API) notifyUserStatusChanged(ctx context.Context, user database.User, status database.UserStatus) error {
880+
var key string
881+
var adminTemplateID, personalTemplateID uuid.UUID
882+
switch status {
883+
case database.UserStatusSuspended:
884+
key = "suspended_account_name"
885+
adminTemplateID = notifications.TemplateUserAccountSuspended
886+
personalTemplateID = notifications.TemplateYourAccountSuspended
887+
case database.UserStatusActive:
888+
key = "activated_account_name"
889+
adminTemplateID = notifications.TemplateUserAccountActivated
890+
personalTemplateID = notifications.TemplateYourAccountActivated
891+
default:
892+
api.Logger.Error(ctx, "user status is not supported", slog.F("username", user.Username), slog.F("user_status", string(status)))
893+
return xerrors.Errorf("unable to notify admins as the user's status is unsupported")
894+
}
895+
896+
userAdmins, err := findUserAdmins(ctx, api.Database)
897+
if err != nil {
898+
api.Logger.Error(ctx, "unable to find user admins", slog.Error(err))
899+
}
870900

871-
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
901+
// Send notifications to user admins and affected user
902+
for _, u := range userAdmins {
903+
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, adminTemplateID,
904+
map[string]string{
905+
key: user.Username,
906+
}, "api-put-user-status",
907+
user.ID,
908+
); err != nil {
909+
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
910+
}
911+
}
912+
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, user.ID, personalTemplateID,
913+
map[string]string{
914+
key: user.Username,
915+
}, "api-put-user-status",
916+
user.ID,
917+
); err != nil {
918+
api.Logger.Warn(ctx, "unable to notify user about status change of their account", slog.F("affected_user", user.Username), slog.Error(err))
872919
}
920+
return nil
873921
}
874922

875923
// @Summary Update user appearance settings

coderd/users_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,110 @@ func TestDeleteUser(t *testing.T) {
374374
})
375375
}
376376

377+
func TestNotifyUserStatusChanged(t *testing.T) {
378+
t.Parallel()
379+
380+
type expectedNotification struct {
381+
TemplateID uuid.UUID
382+
UserID uuid.UUID
383+
}
384+
385+
verifyNotificationDispatched := func(notifyEnq *testutil.FakeNotificationsEnqueuer, expectedNotifications []expectedNotification, member codersdk.User, label string) {
386+
require.Equal(t, len(expectedNotifications), len(notifyEnq.Sent))
387+
388+
// Validate that each expected notification is present in notifyEnq.Sent
389+
for _, expected := range expectedNotifications {
390+
found := false
391+
for _, sent := range notifyEnq.Sent {
392+
if sent.TemplateID == expected.TemplateID &&
393+
sent.UserID == expected.UserID &&
394+
slices.Contains(sent.Targets, member.ID) &&
395+
sent.Labels[label] == member.Username {
396+
found = true
397+
break
398+
}
399+
}
400+
require.True(t, found, "Expected notification not found: %+v", expected)
401+
}
402+
}
403+
404+
t.Run("Account suspended", func(t *testing.T) {
405+
t.Parallel()
406+
407+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
408+
adminClient := coderdtest.New(t, &coderdtest.Options{
409+
NotificationsEnqueuer: notifyEnq,
410+
})
411+
firstUser := coderdtest.CreateFirstUser(t, adminClient)
412+
413+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
414+
defer cancel()
415+
416+
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
417+
418+
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
419+
OrganizationID: firstUser.OrganizationID,
420+
Email: "another@user.org",
421+
Username: "someone-else",
422+
Password: "SomeSecurePassword!",
423+
})
424+
require.NoError(t, err)
425+
426+
notifyEnq.Clear()
427+
428+
// when
429+
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
430+
require.NoError(t, err)
431+
432+
// then
433+
verifyNotificationDispatched(notifyEnq, []expectedNotification{
434+
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: firstUser.UserID},
435+
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: userAdmin.ID},
436+
{TemplateID: notifications.TemplateYourAccountSuspended, UserID: member.ID},
437+
}, member, "suspended_account_name")
438+
})
439+
440+
t.Run("Account reactivated", func(t *testing.T) {
441+
t.Parallel()
442+
443+
// given
444+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
445+
adminClient := coderdtest.New(t, &coderdtest.Options{
446+
NotificationsEnqueuer: notifyEnq,
447+
})
448+
firstUser := coderdtest.CreateFirstUser(t, adminClient)
449+
450+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
451+
defer cancel()
452+
453+
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
454+
455+
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
456+
OrganizationID: firstUser.OrganizationID,
457+
Email: "another@user.org",
458+
Username: "someone-else",
459+
Password: "SomeSecurePassword!",
460+
})
461+
require.NoError(t, err)
462+
463+
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
464+
require.NoError(t, err)
465+
466+
notifyEnq.Clear()
467+
468+
// when
469+
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusActive)
470+
require.NoError(t, err)
471+
472+
// then
473+
verifyNotificationDispatched(notifyEnq, []expectedNotification{
474+
{TemplateID: notifications.TemplateUserAccountActivated, UserID: firstUser.UserID},
475+
{TemplateID: notifications.TemplateUserAccountActivated, UserID: userAdmin.ID},
476+
{TemplateID: notifications.TemplateYourAccountActivated, UserID: member.ID},
477+
}, member, "activated_account_name")
478+
})
479+
}
480+
377481
func TestNotifyDeletedUser(t *testing.T) {
378482
t.Parallel()
379483

0 commit comments

Comments
 (0)