Add calendar integrations and collaboration updates
This commit is contained in:
@@ -4,6 +4,7 @@ public class Organization
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class OrganizationModelConfiguration
|
||||
organization.ToTable("Organizations");
|
||||
organization.HasKey(x => x.Id);
|
||||
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
organization.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
public record AddOrganizationMemberRequest(
|
||||
string Email,
|
||||
string Role);
|
||||
|
||||
public class AddOrganizationMemberRequestValidator
|
||||
: Validator<AddOrganizationMemberRequest>
|
||||
{
|
||||
private static readonly string[] AllowedRoles =
|
||||
[
|
||||
OrganizationRoles.Admin,
|
||||
OrganizationRoles.BillingManager,
|
||||
OrganizationRoles.ConnectorManager,
|
||||
OrganizationRoles.Member,
|
||||
];
|
||||
|
||||
public AddOrganizationMemberRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
|
||||
RuleFor(x => x.Role)
|
||||
.NotEmpty()
|
||||
.Must(role => AllowedRoles.Contains(role.Trim(), StringComparer.Ordinal))
|
||||
.WithMessage("A valid organization role should be specified.");
|
||||
}
|
||||
}
|
||||
|
||||
public class AddOrganizationMemberHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<AddOrganizationMemberRequest, OrganizationMemberDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/organizations/{organizationId:guid}/members");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(AddOrganizationMemberRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid organizationId = Route<Guid>("organizationId");
|
||||
|
||||
if (!await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId, ct))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageOrganizationMembers,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedEmail = request.Email.Trim().ToUpperInvariant();
|
||||
User? user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(candidate => candidate.NormalizedEmail == normalizedEmail, ct);
|
||||
if (user is null)
|
||||
{
|
||||
AddError(request => request.Email, "No user account exists for this email address.");
|
||||
await SendErrorsAsync(StatusCodes.Status404NotFound, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool duplicateMembership = await dbContext.OrganizationMemberships.AnyAsync(
|
||||
membership => membership.OrganizationId == organizationId && membership.UserId == user.Id,
|
||||
ct);
|
||||
if (duplicateMembership)
|
||||
{
|
||||
AddError(request => request.Email, "This user is already a member of the organization.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string role = request.Role.Trim();
|
||||
OrganizationMembership membership = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organizationId,
|
||||
UserId = user.Id,
|
||||
Role = role,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.OrganizationMemberships.Add(membership);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(
|
||||
new OrganizationMemberDto(
|
||||
user.Id,
|
||||
BuildDisplayName(user),
|
||||
user.Email ?? string.Empty,
|
||||
user.PortraitUrl,
|
||||
membership.Role,
|
||||
OrganizationPermissionRules.GetPermissionsForRole(membership.Role),
|
||||
membership.CreatedAt),
|
||||
StatusCodes.Status201Created,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static string BuildDisplayName(User user)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.Alias))
|
||||
{
|
||||
return user.Alias;
|
||||
}
|
||||
|
||||
string fullName = $"{user.Firstname} {user.Lastname}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(fullName))
|
||||
{
|
||||
return fullName;
|
||||
}
|
||||
|
||||
return user.Email ?? user.UserName ?? user.Id.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
public record ChangeOrganizationLogoRequest(
|
||||
IFormFile File);
|
||||
|
||||
public record ChangeOrganizationLogoResponse(
|
||||
string BlobUrl);
|
||||
|
||||
public sealed class ChangeOrganizationLogoRequestValidator : Validator<ChangeOrganizationLogoRequest>
|
||||
{
|
||||
public ChangeOrganizationLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeOrganizationLogoHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<ChangeOrganizationLogoRequest, ChangeOrganizationLogoResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/organizations/{organizationId:guid}/logo");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeOrganizationLogoRequest 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.ManageOrganizationSettings,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Organizations,
|
||||
$"{organization.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
organization.LogoUrl = blobUrl;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(new ChangeOrganizationLogoResponse(blobUrl), ct);
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,15 @@ public class GetOrganizationHandler(
|
||||
|
||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
|
||||
OrganizationUsageDto usage = await GetUsageAsync(organization, ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
members,
|
||||
workspaces),
|
||||
workspaces,
|
||||
usage),
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -96,6 +98,57 @@ public class GetOrganizationHandler(
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<OrganizationUsageDto> GetUsageAsync(
|
||||
Organization organization,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid[] workspaceIds = await dbContext.Workspaces
|
||||
.Where(workspace => workspace.OrganizationId == organization.Id)
|
||||
.Select(workspace => workspace.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
Guid[] memberUserIds = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.OrganizationId == organization.Id)
|
||||
.Select(membership => membership.UserId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(ct);
|
||||
int userCount = memberUserIds
|
||||
.Append(organization.OwnerUserId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
int activeContentItemCount = workspaceIds.Length == 0
|
||||
? 0
|
||||
: await dbContext.ContentItems
|
||||
.Where(contentItem => workspaceIds.Contains(contentItem.WorkspaceId) &&
|
||||
contentItem.Status != "Approved" &&
|
||||
contentItem.Status != "Scheduled")
|
||||
.CountAsync(ct);
|
||||
|
||||
OrganizationUsageLimits limits = GetUsageLimits(organization.Name);
|
||||
|
||||
return new OrganizationUsageDto(
|
||||
limits.PlanName,
|
||||
[
|
||||
new OrganizationUsageItemDto("users", userCount, limits.UserLimit),
|
||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, limits.WorkspaceLimit),
|
||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, limits.ActiveContentLimit),
|
||||
]);
|
||||
}
|
||||
|
||||
private static OrganizationUsageLimits GetUsageLimits(string organizationName)
|
||||
{
|
||||
return string.Equals(organizationName, "Northstar Agency", StringComparison.OrdinalIgnoreCase)
|
||||
? new OrganizationUsageLimits("Agency", 25, 15, 250)
|
||||
: new OrganizationUsageLimits("Free", 2, 1, 3);
|
||||
}
|
||||
|
||||
private sealed record OrganizationUsageLimits(
|
||||
string PlanName,
|
||||
int UserLimit,
|
||||
int WorkspaceLimit,
|
||||
int ActiveContentLimit);
|
||||
|
||||
private static string BuildDisplayName(User user)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.Alias))
|
||||
|
||||
@@ -15,25 +15,39 @@ public record OrganizationMemberDto(
|
||||
public record OrganizationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? LogoUrl,
|
||||
Guid OwnerUserId,
|
||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||
OrganizationUsageDto? Usage,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static OrganizationDto FromOrganization(
|
||||
Organization organization,
|
||||
IReadOnlyCollection<string> currentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null)
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null,
|
||||
OrganizationUsageDto? usage = null)
|
||||
{
|
||||
return new OrganizationDto(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
organization.LogoUrl,
|
||||
organization.OwnerUserId,
|
||||
currentUserPermissions,
|
||||
members ?? [],
|
||||
workspaces ?? [],
|
||||
usage,
|
||||
organization.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public record OrganizationUsageDto(
|
||||
string PlanName,
|
||||
IReadOnlyCollection<OrganizationUsageItemDto> Items);
|
||||
|
||||
public record OrganizationUsageItemDto(
|
||||
string Key,
|
||||
int Used,
|
||||
int? Limit);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
|
||||
public record UpdateOrganizationRequest(
|
||||
string Name);
|
||||
|
||||
public class UpdateOrganizationRequestValidator
|
||||
: Validator<UpdateOrganizationRequest>
|
||||
{
|
||||
public UpdateOrganizationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateOrganizationHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpdateOrganizationRequest, OrganizationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/organizations/{organizationId:guid}");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateOrganizationRequest 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.ManageOrganizationSettings,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
organization.Name = request.Name.Trim();
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organizationId,
|
||||
ct);
|
||||
|
||||
await SendOkAsync(OrganizationDto.FromOrganization(organization, currentUserPermissions), ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user