Skip to content

feat: add notification for suspended/activated account #14367

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 22, 2024
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions coderd/notifications/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 55 additions & 3 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -857,7 +857,16 @@ 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 {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error notifying about changed user status.",
Detail: err.Error(),
})
return
}

organizations, err := userOrganizationIDs(ctx, api, user)
if err != nil {
Expand All @@ -867,9 +876,52 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
})
return
}
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, ownerTemplateID uuid.UUID
switch status {
case database.UserStatusSuspended:
key = "suspended_account_name"
adminTemplateID = notifications.TemplateUserAccountSuspended
ownerTemplateID = notifications.TemplateYourAccountSuspended
case database.UserStatusActive:
key = "activated_account_name"
adminTemplateID = notifications.TemplateUserAccountActivated
ownerTemplateID = 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 {
return xerrors.Errorf("unable to find user admins: %w", err)
}

httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
// 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, ownerTemplateID,
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
Expand Down
104 changes: 104 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading