Skip to content

Commit c818b4d

Browse files
authored
feat: add notification for suspended/activated account (#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)
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