feat: add database backed membership tiers
This commit is contained in:
@@ -5,6 +5,7 @@ internal class Organization
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
internal class OrganizationMembershipTier
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public int? MonthlyPriceCents { get; set; }
|
||||
public int? WorkspaceLimit { get; set; }
|
||||
public int? ActiveContentLimit { get; set; }
|
||||
public int? MemberLimit { get; set; }
|
||||
public int? ExternalReviewerLimit { get; set; }
|
||||
public bool IsCustom { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
internal static class OrganizationMembershipTierSeed
|
||||
{
|
||||
public static readonly Guid FreeId = Guid.Parse("20000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid FreelanceId = Guid.Parse("20000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid AgencyId = Guid.Parse("20000000-0000-0000-0000-000000000003");
|
||||
public static readonly Guid EnterpriseId = Guid.Parse("20000000-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly OrganizationMembershipTier[] Tiers =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = FreeId,
|
||||
Key = "free",
|
||||
Name = "Free",
|
||||
Description = "For trying Socialize on one real approval workflow.",
|
||||
MonthlyPriceCents = 0,
|
||||
WorkspaceLimit = 1,
|
||||
ActiveContentLimit = 3,
|
||||
MemberLimit = 2,
|
||||
ExternalReviewerLimit = 1,
|
||||
SortOrder = 10,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = FreelanceId,
|
||||
Key = "freelance",
|
||||
Name = "Freelance",
|
||||
Description = "For solo operators managing recurring client reviews.",
|
||||
MonthlyPriceCents = 1900,
|
||||
WorkspaceLimit = 3,
|
||||
ActiveContentLimit = 25,
|
||||
MemberLimit = 5,
|
||||
ExternalReviewerLimit = 10,
|
||||
SortOrder = 20,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = AgencyId,
|
||||
Key = "agency",
|
||||
Name = "Agency",
|
||||
Description = "For agencies that need repeatable client approval operations.",
|
||||
MonthlyPriceCents = 7900,
|
||||
WorkspaceLimit = 15,
|
||||
ActiveContentLimit = 250,
|
||||
MemberLimit = 25,
|
||||
ExternalReviewerLimit = null,
|
||||
SortOrder = 30,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = EnterpriseId,
|
||||
Key = "enterprise",
|
||||
Name = "Enterprise",
|
||||
Description = "For larger organizations with governance and access needs.",
|
||||
MonthlyPriceCents = null,
|
||||
WorkspaceLimit = null,
|
||||
ActiveContentLimit = null,
|
||||
MemberLimit = null,
|
||||
ExternalReviewerLimit = null,
|
||||
IsCustom = true,
|
||||
SortOrder = 40,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -12,10 +12,29 @@ internal static class OrganizationModelConfiguration
|
||||
organization.HasKey(x => x.Id);
|
||||
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
organization.Property(x => x.MembershipTierId)
|
||||
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
|
||||
organization.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
organization.HasIndex(x => x.MembershipTierId);
|
||||
organization.HasIndex(x => x.OwnerUserId);
|
||||
organization.HasOne<OrganizationMembershipTier>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.MembershipTierId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrganizationMembershipTier>(tier =>
|
||||
{
|
||||
tier.ToTable("OrganizationMembershipTiers");
|
||||
tier.HasKey(x => x.Id);
|
||||
tier.Property(x => x.Key).HasMaxLength(64).IsRequired();
|
||||
tier.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
tier.Property(x => x.Description).HasMaxLength(512).IsRequired();
|
||||
tier.HasIndex(x => x.Key).IsUnique();
|
||||
tier.HasIndex(x => x.SortOrder);
|
||||
tier.HasData(OrganizationMembershipTierSeed.Tiers);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrganizationMembership>(membership =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
@@ -7,7 +8,8 @@ using Socialize.Api.Modules.Organizations.Services;
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal record CreateOrganizationRequest(
|
||||
string Name);
|
||||
string Name,
|
||||
Guid? MembershipTierId = null);
|
||||
|
||||
internal class CreateOrganizationRequestValidator
|
||||
: Validator<CreateOrganizationRequest>
|
||||
@@ -32,11 +34,22 @@ internal class CreateOrganizationHandler(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid membershipTierId = request.MembershipTierId ?? OrganizationMembershipTierSeed.FreeId;
|
||||
OrganizationMembershipTier? membershipTier = await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == membershipTierId, ct);
|
||||
if (membershipTier is null)
|
||||
{
|
||||
AddError(request => request.MembershipTierId, "The selected membership tier does not exist.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid userId = User.GetUserId();
|
||||
Organization organization = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name.Trim(),
|
||||
MembershipTierId = membershipTier.Id,
|
||||
OwnerUserId = userId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
@@ -57,7 +70,8 @@ internal class CreateOrganizationHandler(
|
||||
await SendAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner)),
|
||||
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner),
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||
StatusCodes.Status201Created,
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
@@ -44,15 +46,19 @@ internal class GetOrganizationHandler(
|
||||
|
||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
|
||||
OrganizationUsageDto usage = await GetUsageAsync(organization, ct);
|
||||
OrganizationMembershipTier membershipTier = await GetMembershipTierAsync(organization.MembershipTierId, ct);
|
||||
OrganizationUsageDto usage = await GetUsageAsync(organization, membershipTier, ct);
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto> availableMembershipTiers = await GetAvailableMembershipTiersAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier),
|
||||
members,
|
||||
workspaces,
|
||||
usage),
|
||||
usage,
|
||||
availableMembershipTiers),
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -100,6 +106,7 @@ internal class GetOrganizationHandler(
|
||||
|
||||
private async Task<OrganizationUsageDto> GetUsageAsync(
|
||||
Organization organization,
|
||||
OrganizationMembershipTier membershipTier,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid[] workspaceIds = await dbContext.Workspaces
|
||||
@@ -125,29 +132,80 @@ internal class GetOrganizationHandler(
|
||||
contentItem.Status != "Scheduled")
|
||||
.CountAsync(ct);
|
||||
|
||||
OrganizationUsageLimits limits = GetUsageLimits(organization.Name);
|
||||
int externalReviewerCount = workspaceIds.Length == 0
|
||||
? 0
|
||||
: await GetExternalReviewerCountAsync(workspaceIds, memberUserIds, organization.OwnerUserId, ct);
|
||||
|
||||
return new OrganizationUsageDto(
|
||||
limits.PlanName,
|
||||
membershipTier.Key,
|
||||
membershipTier.Name,
|
||||
[
|
||||
new OrganizationUsageItemDto("users", userCount, limits.UserLimit),
|
||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, limits.WorkspaceLimit),
|
||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, limits.ActiveContentLimit),
|
||||
new OrganizationUsageItemDto("users", userCount, membershipTier.MemberLimit),
|
||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, membershipTier.WorkspaceLimit),
|
||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, membershipTier.ActiveContentLimit),
|
||||
new OrganizationUsageItemDto("externalReviewers", externalReviewerCount, membershipTier.ExternalReviewerLimit),
|
||||
]);
|
||||
}
|
||||
|
||||
private static OrganizationUsageLimits GetUsageLimits(string organizationName)
|
||||
private async Task<OrganizationMembershipTier> GetMembershipTierAsync(Guid membershipTierId, CancellationToken ct)
|
||||
{
|
||||
return string.Equals(organizationName, "Northstar Agency", StringComparison.OrdinalIgnoreCase)
|
||||
? new OrganizationUsageLimits("Agency", 25, 15, 250)
|
||||
: new OrganizationUsageLimits("Free", 2, 1, 3);
|
||||
return await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == membershipTierId, ct)
|
||||
?? await dbContext.OrganizationMembershipTiers
|
||||
.SingleAsync(tier => tier.Id == OrganizationMembershipTierSeed.FreeId, ct);
|
||||
}
|
||||
|
||||
private sealed record OrganizationUsageLimits(
|
||||
string PlanName,
|
||||
int UserLimit,
|
||||
int WorkspaceLimit,
|
||||
int ActiveContentLimit);
|
||||
private async Task<IReadOnlyCollection<OrganizationMembershipTierDto>> GetAvailableMembershipTiersAsync(CancellationToken ct)
|
||||
{
|
||||
List<OrganizationMembershipTier> tiers = await dbContext.OrganizationMembershipTiers
|
||||
.OrderBy(tier => tier.SortOrder)
|
||||
.ThenBy(tier => tier.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return tiers
|
||||
.Select(OrganizationMembershipTierDto.FromTier)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<int> GetExternalReviewerCountAsync(
|
||||
IReadOnlyCollection<Guid> workspaceIds,
|
||||
IReadOnlyCollection<Guid> organizationMemberUserIds,
|
||||
Guid ownerUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string[] workspaceClaimValues = workspaceIds
|
||||
.Select(id => id.ToString())
|
||||
.ToArray();
|
||||
|
||||
HashSet<Guid> internalUserIds = organizationMemberUserIds
|
||||
.Append(ownerUserId)
|
||||
.ToHashSet();
|
||||
|
||||
Guid[] scopedUserIds = await dbContext.UserClaims
|
||||
.Where(claim => claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||
workspaceClaimValues.Contains(claim.ClaimValue!))
|
||||
.Select(claim => claim.UserId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
if (scopedUserIds.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Guid[] clientRoleIds = await dbContext.Roles
|
||||
.Where(role => role.Name == KnownRoles.Client)
|
||||
.Select(role => role.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return await dbContext.UserRoles
|
||||
.Where(userRole => scopedUserIds.Contains(userRole.UserId) &&
|
||||
clientRoleIds.Contains(userRole.RoleId) &&
|
||||
!internalUserIds.Contains(userRole.UserId))
|
||||
.Select(userRole => userRole.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
private static string BuildDisplayName(User user)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,18 @@ internal class GetOrganizationsHandler(
|
||||
.OrderBy(organization => organization.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
Guid[] membershipTierIds = organizations
|
||||
.Select(organization => organization.MembershipTierId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
List<OrganizationMembershipTier> membershipTierModels = await dbContext.OrganizationMembershipTiers
|
||||
.Where(tier => membershipTierIds.Contains(tier.Id))
|
||||
.ToListAsync(ct);
|
||||
Dictionary<Guid, OrganizationMembershipTierDto> membershipTiersById = membershipTierModels
|
||||
.ToDictionary(
|
||||
tier => tier.Id,
|
||||
OrganizationMembershipTierDto.FromTier);
|
||||
|
||||
List<OrganizationDto> response = [];
|
||||
foreach (Organization organization in organizations)
|
||||
{
|
||||
@@ -33,7 +45,10 @@ internal class GetOrganizationsHandler(
|
||||
User,
|
||||
organization.Id,
|
||||
ct);
|
||||
response.Add(OrganizationDto.FromOrganization(organization, permissions));
|
||||
response.Add(OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
permissions,
|
||||
membershipTiersById.GetValueOrDefault(organization.MembershipTierId)));
|
||||
}
|
||||
|
||||
await SendOkAsync(response, ct);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal class ListOrganizationMembershipTiersHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<OrganizationMembershipTierDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organization-membership-tiers");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<OrganizationMembershipTier> tierModels = await dbContext.OrganizationMembershipTiers
|
||||
.OrderBy(tier => tier.SortOrder)
|
||||
.ThenBy(tier => tier.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
OrganizationMembershipTierDto[] tiers = tierModels
|
||||
.Select(OrganizationMembershipTierDto.FromTier)
|
||||
.ToArray();
|
||||
|
||||
await SendOkAsync(tiers, ct);
|
||||
}
|
||||
}
|
||||
@@ -16,34 +16,71 @@ internal record OrganizationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? LogoUrl,
|
||||
OrganizationMembershipTierDto? MembershipTier,
|
||||
Guid OwnerUserId,
|
||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||
OrganizationUsageDto? Usage,
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto> AvailableMembershipTiers,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static OrganizationDto FromOrganization(
|
||||
Organization organization,
|
||||
IReadOnlyCollection<string> currentUserPermissions,
|
||||
OrganizationMembershipTierDto? membershipTier = null,
|
||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null,
|
||||
OrganizationUsageDto? usage = null)
|
||||
OrganizationUsageDto? usage = null,
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto>? availableMembershipTiers = null)
|
||||
{
|
||||
return new OrganizationDto(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
organization.LogoUrl,
|
||||
membershipTier,
|
||||
organization.OwnerUserId,
|
||||
currentUserPermissions,
|
||||
members ?? [],
|
||||
workspaces ?? [],
|
||||
usage,
|
||||
availableMembershipTiers ?? [],
|
||||
organization.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
internal record OrganizationMembershipTierDto(
|
||||
Guid Id,
|
||||
string Key,
|
||||
string Name,
|
||||
string Description,
|
||||
int? MonthlyPriceCents,
|
||||
int? WorkspaceLimit,
|
||||
int? ActiveContentLimit,
|
||||
int? MemberLimit,
|
||||
int? ExternalReviewerLimit,
|
||||
bool IsCustom,
|
||||
int SortOrder)
|
||||
{
|
||||
public static OrganizationMembershipTierDto FromTier(OrganizationMembershipTier tier)
|
||||
{
|
||||
return new OrganizationMembershipTierDto(
|
||||
tier.Id,
|
||||
tier.Key,
|
||||
tier.Name,
|
||||
tier.Description,
|
||||
tier.MonthlyPriceCents,
|
||||
tier.WorkspaceLimit,
|
||||
tier.ActiveContentLimit,
|
||||
tier.MemberLimit,
|
||||
tier.ExternalReviewerLimit,
|
||||
tier.IsCustom,
|
||||
tier.SortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
internal record OrganizationUsageDto(
|
||||
string PlanKey,
|
||||
string PlanName,
|
||||
IReadOnlyCollection<OrganizationUsageItemDto> Items);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal record UpdateOrganizationMembershipTierRequest(
|
||||
Guid MembershipTierId);
|
||||
|
||||
internal class UpdateOrganizationMembershipTierRequestValidator
|
||||
: Validator<UpdateOrganizationMembershipTierRequest>
|
||||
{
|
||||
public UpdateOrganizationMembershipTierRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.MembershipTierId).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
internal class UpdateOrganizationMembershipTierHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpdateOrganizationMembershipTierRequest, OrganizationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/organizations/{organizationId:guid}/membership-tier");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateOrganizationMembershipTierRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid organizationId = Route<Guid>("organizationId");
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageBilling,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
OrganizationMembershipTier? membershipTier = await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == request.MembershipTierId, ct);
|
||||
if (membershipTier is null)
|
||||
{
|
||||
AddError(x => x.MembershipTierId, "The selected membership tier does not exist.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
organization.MembershipTierId = membershipTier.Id;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organizationId,
|
||||
ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user