mirror of
https://github.com/coder/coder.git
synced 2025-07-21 01:28:49 +00:00
fix: fix dormancy notifications (#14029)
This commit is contained in:
@ -19,7 +19,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/dormancy"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/schedule"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
@ -145,10 +144,11 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
var (
|
||||
job *database.ProvisionerJob
|
||||
auditLog *auditParams
|
||||
dormantNotification *dormancy.WorkspaceDormantNotification
|
||||
shouldNotifyDormancy bool
|
||||
nextBuild *database.WorkspaceBuild
|
||||
activeTemplateVersion database.TemplateVersion
|
||||
ws database.Workspace
|
||||
tmpl database.Template
|
||||
didAutoUpdate bool
|
||||
)
|
||||
err := e.db.InTx(func(tx database.Store) error {
|
||||
@ -182,17 +182,17 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
return xerrors.Errorf("get template scheduling options: %w", err)
|
||||
}
|
||||
|
||||
template, err := tx.GetTemplateByID(e.ctx, ws.TemplateID)
|
||||
tmpl, err = tx.GetTemplateByID(e.ctx, ws.TemplateID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by ID: %w", err)
|
||||
}
|
||||
|
||||
activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, template.ActiveVersionID)
|
||||
activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, tmpl.ActiveVersionID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get active template version by ID: %w", err)
|
||||
}
|
||||
|
||||
accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template)
|
||||
accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(tmpl)
|
||||
|
||||
nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick)
|
||||
if err != nil {
|
||||
@ -215,7 +215,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
log.Debug(e.ctx, "autostarting with active version")
|
||||
builder = builder.ActiveVersion()
|
||||
|
||||
if latestBuild.TemplateVersionID != template.ActiveVersionID {
|
||||
if latestBuild.TemplateVersionID != tmpl.ActiveVersionID {
|
||||
// control flag to know if the workspace was auto-updated,
|
||||
// so the lifecycle executor can notify the user
|
||||
didAutoUpdate = true
|
||||
@ -248,12 +248,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
return xerrors.Errorf("update workspace dormant deleting at: %w", err)
|
||||
}
|
||||
|
||||
dormantNotification = &dormancy.WorkspaceDormantNotification{
|
||||
Workspace: ws,
|
||||
Initiator: "autobuild",
|
||||
Reason: "breached the template's threshold for inactivity",
|
||||
CreatedBy: "lifecycleexecutor",
|
||||
}
|
||||
shouldNotifyDormancy = true
|
||||
|
||||
log.Info(e.ctx, "dormant workspace",
|
||||
slog.F("last_used_at", ws.LastUsedAt),
|
||||
@ -325,14 +320,24 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
return xerrors.Errorf("post provisioner job to pubsub: %w", err)
|
||||
}
|
||||
}
|
||||
if dormantNotification != nil {
|
||||
_, err = dormancy.NotifyWorkspaceDormant(
|
||||
if shouldNotifyDormancy {
|
||||
_, err = e.notificationsEnqueuer.Enqueue(
|
||||
e.ctx,
|
||||
e.notificationsEnqueuer,
|
||||
*dormantNotification,
|
||||
ws.OwnerID,
|
||||
notifications.TemplateWorkspaceDormant,
|
||||
map[string]string{
|
||||
"name": ws.Name,
|
||||
"reason": "inactivity exceeded the dormancy threshold",
|
||||
"timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(),
|
||||
},
|
||||
"lifecycle_executor",
|
||||
ws.ID,
|
||||
ws.OwnerID,
|
||||
ws.TemplateID,
|
||||
ws.OrganizationID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", dormantNotification.Workspace.ID))
|
||||
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", ws.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@ -1122,7 +1122,6 @@ func TestNotifications(t *testing.T) {
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
|
||||
require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], "autobuild")
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,16 @@
|
||||
UPDATE notification_templates
|
||||
SET
|
||||
body_template = E'Hi {{.UserName}}\n\n' ||
|
||||
E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\n' ||
|
||||
E'Dormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\n' ||
|
||||
E'To prevent deletion, use your workspace with the link below.'
|
||||
WHERE
|
||||
id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';
|
||||
|
||||
UPDATE notification_templates
|
||||
SET
|
||||
body_template = E'Hi {{.UserName}}\n\n' ||
|
||||
E'Your workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\n' ||
|
||||
E'To prevent deletion, use your workspace with the link below.'
|
||||
WHERE
|
||||
id = '51ce2fdf-c9ca-4be1-8d70-628674f9bc42';
|
@ -132,4 +132,3 @@ WHERE id IN
|
||||
|
||||
-- name: GetNotificationMessagesByStatus :many
|
||||
SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int;
|
||||
|
||||
|
@ -1,75 +0,0 @@
|
||||
// This package is located outside of the enterprise package to ensure
|
||||
// accessibility in the putWorkspaceDormant function. This design choice allows
|
||||
// workspaces to be taken out of dormancy even if the license has expired,
|
||||
// ensuring critical functionality remains available without an active
|
||||
// enterprise license.
|
||||
package dormancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
)
|
||||
|
||||
type WorkspaceDormantNotification struct {
|
||||
Workspace database.Workspace
|
||||
Initiator string
|
||||
Reason string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func NotifyWorkspaceDormant(
|
||||
ctx context.Context,
|
||||
enqueuer notifications.Enqueuer,
|
||||
notification WorkspaceDormantNotification,
|
||||
) (id *uuid.UUID, err error) {
|
||||
labels := map[string]string{
|
||||
"name": notification.Workspace.Name,
|
||||
"initiator": notification.Initiator,
|
||||
"reason": notification.Reason,
|
||||
}
|
||||
return enqueuer.Enqueue(
|
||||
ctx,
|
||||
notification.Workspace.OwnerID,
|
||||
notifications.TemplateWorkspaceDormant,
|
||||
labels,
|
||||
notification.CreatedBy,
|
||||
// Associate this notification with all the related entities.
|
||||
notification.Workspace.ID,
|
||||
notification.Workspace.OwnerID,
|
||||
notification.Workspace.TemplateID,
|
||||
notification.Workspace.OrganizationID,
|
||||
)
|
||||
}
|
||||
|
||||
type WorkspaceMarkedForDeletionNotification struct {
|
||||
Workspace database.Workspace
|
||||
Reason string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func NotifyWorkspaceMarkedForDeletion(
|
||||
ctx context.Context,
|
||||
enqueuer notifications.Enqueuer,
|
||||
notification WorkspaceMarkedForDeletionNotification,
|
||||
) (id *uuid.UUID, err error) {
|
||||
labels := map[string]string{
|
||||
"name": notification.Workspace.Name,
|
||||
"reason": notification.Reason,
|
||||
}
|
||||
return enqueuer.Enqueue(
|
||||
ctx,
|
||||
notification.Workspace.OwnerID,
|
||||
notifications.TemplateWorkspaceMarkedForDeletion,
|
||||
labels,
|
||||
notification.CreatedBy,
|
||||
// Associate this notification with all the related entities.
|
||||
notification.Workspace.ID,
|
||||
notification.Workspace.OwnerID,
|
||||
notification.Workspace.TemplateID,
|
||||
notification.Workspace.OrganizationID,
|
||||
)
|
||||
}
|
@ -29,6 +29,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/dispatch"
|
||||
"github.com/coder/coder/v2/coderd/notifications/render"
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
"github.com/coder/coder/v2/coderd/util/syncmap"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -603,6 +604,107 @@ func TestNotifierPaused(t *testing.T) {
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
}
|
||||
|
||||
func TestNotifcationTemplatesBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("This test requires postgres; it relies on the notification templates added by migrations in the database")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id uuid.UUID
|
||||
payload types.MessagePayload
|
||||
}{
|
||||
{
|
||||
name: "TemplateWorkspaceDeleted",
|
||||
id: notifications.TemplateWorkspaceDeleted,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"name": "bobby-workspace",
|
||||
"reason": "autodeleted due to dormancy",
|
||||
"initiator": "autobuild",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateWorkspaceAutobuildFailed",
|
||||
id: notifications.TemplateWorkspaceAutobuildFailed,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"name": "bobby-workspace",
|
||||
"reason": "autostart",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateWorkspaceDormant",
|
||||
id: notifications.TemplateWorkspaceDormant,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"name": "bobby-workspace",
|
||||
"reason": "breached the template's threshold for inactivity",
|
||||
"initiator": "autobuild",
|
||||
"dormancyHours": "24",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateWorkspaceAutoUpdated",
|
||||
id: notifications.TemplateWorkspaceAutoUpdated,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"name": "bobby-workspace",
|
||||
"template_version_name": "1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateWorkspaceMarkedForDeletion",
|
||||
id: notifications.TemplateWorkspaceMarkedForDeletion,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"name": "bobby-workspace",
|
||||
"reason": "template updated to new dormancy policy",
|
||||
"dormancyHours": "24",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, sql := dbtestutil.NewDBWithSQLDB(t)
|
||||
|
||||
var (
|
||||
titleTmpl string
|
||||
bodyTmpl string
|
||||
)
|
||||
err := sql.
|
||||
QueryRow("SELECT title_template, body_template FROM notification_templates WHERE id = $1 LIMIT 1", tc.id).
|
||||
Scan(&titleTmpl, &bodyTmpl)
|
||||
require.NoError(t, err, "failed to query body template for template:", tc.id)
|
||||
|
||||
title, err := render.GoTemplate(titleTmpl, tc.payload, nil)
|
||||
require.NoError(t, err, "failed to render notification title template")
|
||||
require.NotEmpty(t, title, "title should not be empty")
|
||||
|
||||
body, err := render.GoTemplate(bodyTmpl, tc.payload, nil)
|
||||
require.NoError(t, err, "failed to render notification body template")
|
||||
require.NotEmpty(t, body, "body should not be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeHandler struct {
|
||||
mu sync.RWMutex
|
||||
succeeded, failed []string
|
||||
|
@ -1101,13 +1101,11 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab
|
||||
return // failed workspace build initiated by a user should not notify
|
||||
}
|
||||
reason = string(build.Reason)
|
||||
initiator := "autobuild"
|
||||
|
||||
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceAutobuildFailed,
|
||||
map[string]string{
|
||||
"name": workspace.Name,
|
||||
"initiator": initiator,
|
||||
"reason": reason,
|
||||
"name": workspace.Name,
|
||||
"reason": reason,
|
||||
}, "provisionerdserver",
|
||||
// Associate this notification with all the related entities.
|
||||
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
|
||||
|
@ -1797,7 +1797,6 @@ func TestNotifications(t *testing.T) {
|
||||
require.Contains(t, notifEnq.Sent[0].Targets, workspace.ID)
|
||||
require.Contains(t, notifEnq.Sent[0].Targets, workspace.OrganizationID)
|
||||
require.Contains(t, notifEnq.Sent[0].Targets, user.ID)
|
||||
require.Equal(t, "autobuild", notifEnq.Sent[0].Labels["initiator"])
|
||||
require.Equal(t, string(tc.buildReason), notifEnq.Sent[0].Labels["reason"])
|
||||
} else {
|
||||
require.Len(t, notifEnq.Sent, 0)
|
||||
|
@ -23,9 +23,9 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||||
"github.com/coder/coder/v2/coderd/dormancy"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
@ -953,25 +953,43 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// We don't need to notify the owner if they are the one making the request.
|
||||
if req.Dormant && apiKey.UserID != workspace.OwnerID {
|
||||
initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID)
|
||||
if err != nil {
|
||||
initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID)
|
||||
if initiatorErr != nil {
|
||||
api.Logger.Warn(
|
||||
ctx,
|
||||
"failed to fetch the user that marked the workspace",
|
||||
"failed to fetch the user that marked the workspace as dormant",
|
||||
slog.Error(err),
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.F("user_id", apiKey.UserID),
|
||||
)
|
||||
} else {
|
||||
_, err = dormancy.NotifyWorkspaceDormant(
|
||||
}
|
||||
|
||||
tmpl, tmplErr := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
if tmplErr != nil {
|
||||
api.Logger.Warn(
|
||||
ctx,
|
||||
api.NotificationsEnqueuer,
|
||||
dormancy.WorkspaceDormantNotification{
|
||||
Workspace: workspace,
|
||||
Initiator: initiator.Username,
|
||||
Reason: "requested by user",
|
||||
CreatedBy: "api",
|
||||
"failed to fetch the template of the workspace marked as dormant",
|
||||
slog.Error(err),
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.F("template_id", workspace.TemplateID),
|
||||
)
|
||||
}
|
||||
|
||||
if initiatorErr == nil && tmplErr == nil {
|
||||
_, err = api.NotificationsEnqueuer.Enqueue(
|
||||
ctx,
|
||||
workspace.OwnerID,
|
||||
notifications.TemplateWorkspaceDormant,
|
||||
map[string]string{
|
||||
"name": workspace.Name,
|
||||
"reason": "a " + initiator.Username + " request",
|
||||
"timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(),
|
||||
},
|
||||
"api",
|
||||
workspace.ID,
|
||||
workspace.OwnerID,
|
||||
workspace.TemplateID,
|
||||
workspace.OrganizationID,
|
||||
)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))
|
||||
|
@ -3457,13 +3457,13 @@ func TestNotifications(t *testing.T) {
|
||||
IncludeProvisionerDaemon: true,
|
||||
NotificationsEnqueuer: notifyEnq,
|
||||
})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner())
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner())
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@ -3483,7 +3483,6 @@ func TestNotifications(t *testing.T) {
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
|
||||
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
|
||||
require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], member.Username)
|
||||
})
|
||||
|
||||
t.Run("InitiatorIsOwner", func(t *testing.T) {
|
||||
|
@ -16,7 +16,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/dormancy"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
agpl "github.com/coder/coder/v2/coderd/schedule"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
@ -205,18 +204,25 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
|
||||
return database.Template{}, err
|
||||
}
|
||||
|
||||
for _, workspace := range markedForDeletion {
|
||||
_, err = dormancy.NotifyWorkspaceMarkedForDeletion(
|
||||
for _, ws := range markedForDeletion {
|
||||
_, err = s.enqueuer.Enqueue(
|
||||
ctx,
|
||||
s.enqueuer,
|
||||
dormancy.WorkspaceMarkedForDeletionNotification{
|
||||
Workspace: workspace,
|
||||
Reason: "template updated to new dormancy policy",
|
||||
CreatedBy: "scheduletemplate",
|
||||
ws.OwnerID,
|
||||
notifications.TemplateWorkspaceMarkedForDeletion,
|
||||
map[string]string{
|
||||
"name": ws.Name,
|
||||
"reason": "an update to the template's dormancy",
|
||||
"timeTilDormant": opts.TimeTilDormantAutoDelete.String(),
|
||||
},
|
||||
"scheduletemplate",
|
||||
// Associate this notification with all the related entities.
|
||||
ws.ID,
|
||||
ws.OwnerID,
|
||||
ws.TemplateID,
|
||||
ws.OrganizationID,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||||
s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", ws.ID))
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user