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
This commit is contained in:
Marcin Tojek
2024-08-22 13:52:25 +02:00
committed by GitHub
parent 046c1c4228
commit c818b4ddd4
6 changed files with 236 additions and 4 deletions
@@ -0,0 +1,4 @@
DELETE FROM notification_templates WHERE id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d';
DELETE FROM notification_templates WHERE id = '6a2f0609-9b69-4d36-a989-9f5925b6cbff';
DELETE FROM notification_templates WHERE id = '9f5af851-8408-4e73-a7a1-c6502ba46689';
DELETE FROM notification_templates WHERE id = '1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4';
@@ -0,0 +1,31 @@
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('b02ddd82-4733-4d02-a2d7-c36f3598997d', 'User account suspended', E'User account "{{.Labels.suspended_account_name}}" suspended',
E'Hi {{.UserName}},\nUser account **{{.Labels.suspended_account_name}}** has been suspended.',
'User Events', '[
{
"label": "View suspended accounts",
"url": "{{ base_url }}/deployment/users?filter=status%3Asuspended"
}
]'::jsonb);
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('6a2f0609-9b69-4d36-a989-9f5925b6cbff', 'Your account has been suspended', E'Your account "{{.Labels.suspended_account_name}}" has been suspended',
E'Hi {{.UserName}},\nYour account **{{.Labels.suspended_account_name}}** has been suspended.',
'User Events', '[]'::jsonb);
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('9f5af851-8408-4e73-a7a1-c6502ba46689', 'User account activated', E'User account "{{.Labels.activated_account_name}}" activated',
E'Hi {{.UserName}},\nUser account **{{.Labels.activated_account_name}}** has been activated.',
'User Events', '[
{
"label": "View accounts",
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
}
]'::jsonb);
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4', 'Your account has been activated', E'Your account "{{.Labels.activated_account_name}}" has been activated',
E'Hi {{.UserName}},\nYour account **{{.Labels.activated_account_name}}** has been activated.',
'User Events', '[
{
"label": "Open Coder",
"url": "{{ base_url }}"
}
]'::jsonb);
+5
View File
@@ -18,6 +18,11 @@ var (
var (
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")
TemplateUserAccountSuspended = uuid.MustParse("b02ddd82-4733-4d02-a2d7-c36f3598997d")
TemplateUserAccountActivated = uuid.MustParse("9f5af851-8408-4e73-a7a1-c6502ba46689")
TemplateYourAccountSuspended = uuid.MustParse("6a2f0609-9b69-4d36-a989-9f5925b6cbff")
TemplateYourAccountActivated = uuid.MustParse("1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4")
)
// Template-related events.
@@ -756,6 +756,46 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
},
},
},
{
name: "TemplateUserAccountSuspended",
id: notifications.TemplateUserAccountSuspended,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"suspended_account_name": "bobby",
},
},
},
{
name: "TemplateUserAccountActivated",
id: notifications.TemplateUserAccountActivated,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"activated_account_name": "bobby",
},
},
},
{
name: "TemplateYourAccountSuspended",
id: notifications.TemplateYourAccountSuspended,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"suspended_account_name": "bobby",
},
},
},
{
name: "TemplateYourAccountActivated",
id: notifications.TemplateYourAccountActivated,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"activated_account_name": "bobby",
},
},
},
{
name: "TemplateTemplateDeleted",
id: notifications.TemplateTemplateDeleted,
+52 -4
View File
@@ -845,7 +845,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
}
}
suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
targetUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
ID: user.ID,
Status: status,
UpdatedAt: dbtime.Now(),
@@ -857,7 +857,12 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
})
return
}
aReq.New = suspendedUser
aReq.New = targetUser
err = api.notifyUserStatusChanged(ctx, user, status)
if err != nil {
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
}
organizations, err := userOrganizationIDs(ctx, api, user)
if err != nil {
@@ -867,11 +872,54 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations))
}
}
func (api *API) notifyUserStatusChanged(ctx context.Context, user database.User, status database.UserStatus) error {
var key string
var adminTemplateID, personalTemplateID uuid.UUID
switch status {
case database.UserStatusSuspended:
key = "suspended_account_name"
adminTemplateID = notifications.TemplateUserAccountSuspended
personalTemplateID = notifications.TemplateYourAccountSuspended
case database.UserStatusActive:
key = "activated_account_name"
adminTemplateID = notifications.TemplateUserAccountActivated
personalTemplateID = notifications.TemplateYourAccountActivated
default:
api.Logger.Error(ctx, "user status is not supported", slog.F("username", user.Username), slog.F("user_status", string(status)))
return xerrors.Errorf("unable to notify admins as the user's status is unsupported")
}
userAdmins, err := findUserAdmins(ctx, api.Database)
if err != nil {
api.Logger.Error(ctx, "unable to find user admins", slog.Error(err))
}
// Send notifications to user admins and affected user
for _, u := range userAdmins {
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, adminTemplateID,
map[string]string{
key: user.Username,
}, "api-put-user-status",
user.ID,
); err != nil {
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
}
}
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, user.ID, personalTemplateID,
map[string]string{
key: user.Username,
}, "api-put-user-status",
user.ID,
); err != nil {
api.Logger.Warn(ctx, "unable to notify user about status change of their account", slog.F("affected_user", user.Username), slog.Error(err))
}
return nil
}
// @Summary Update user appearance settings
// @ID update-user-appearance-settings
// @Security CoderSessionToken
+104
View File
@@ -374,6 +374,110 @@ func TestDeleteUser(t *testing.T) {
})
}
func TestNotifyUserStatusChanged(t *testing.T) {
t.Parallel()
type expectedNotification struct {
TemplateID uuid.UUID
UserID uuid.UUID
}
verifyNotificationDispatched := func(notifyEnq *testutil.FakeNotificationsEnqueuer, expectedNotifications []expectedNotification, member codersdk.User, label string) {
require.Equal(t, len(expectedNotifications), len(notifyEnq.Sent))
// Validate that each expected notification is present in notifyEnq.Sent
for _, expected := range expectedNotifications {
found := false
for _, sent := range notifyEnq.Sent {
if sent.TemplateID == expected.TemplateID &&
sent.UserID == expected.UserID &&
slices.Contains(sent.Targets, member.ID) &&
sent.Labels[label] == member.Username {
found = true
break
}
}
require.True(t, found, "Expected notification not found: %+v", expected)
}
}
t.Run("Account suspended", func(t *testing.T) {
t.Parallel()
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
adminClient := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
OrganizationID: firstUser.OrganizationID,
Email: "another@user.org",
Username: "someone-else",
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
notifyEnq.Clear()
// when
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)
// then
verifyNotificationDispatched(notifyEnq, []expectedNotification{
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: firstUser.UserID},
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: userAdmin.ID},
{TemplateID: notifications.TemplateYourAccountSuspended, UserID: member.ID},
}, member, "suspended_account_name")
})
t.Run("Account reactivated", func(t *testing.T) {
t.Parallel()
// given
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
adminClient := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
OrganizationID: firstUser.OrganizationID,
Email: "another@user.org",
Username: "someone-else",
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)
notifyEnq.Clear()
// when
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusActive)
require.NoError(t, err)
// then
verifyNotificationDispatched(notifyEnq, []expectedNotification{
{TemplateID: notifications.TemplateUserAccountActivated, UserID: firstUser.UserID},
{TemplateID: notifications.TemplateUserAccountActivated, UserID: userAdmin.ID},
{TemplateID: notifications.TemplateYourAccountActivated, UserID: member.ID},
}, member, "activated_account_name")
})
}
func TestNotifyDeletedUser(t *testing.T) {
t.Parallel()