Add calendar integrations and collaboration updates
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-05-05 15:25:53 -04:00
parent c49f03ec06
commit b66c10b681
82 changed files with 8420 additions and 2048 deletions

View File

@@ -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; }
}

View File

@@ -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");

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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))

View File

@@ -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);

View File

@@ -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);
}
}