feat: add organization domain foundation

This commit is contained in:
2026-05-04 16:15:53 -04:00
parent 802668fb0b
commit 7d3f495472
55 changed files with 2995 additions and 115 deletions

View File

@@ -6,6 +6,7 @@ public class Workspace
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public string ApprovalMode { get; set; } = "Required";

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Organizations.Data;
namespace Socialize.Api.Modules.Workspaces.Data;
@@ -22,7 +23,12 @@ public static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.Slug).IsUnique();
workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>

View File

@@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -7,6 +7,7 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record CreateWorkspaceRequest(
Guid OrganizationId,
string Name,
string Slug,
string TimeZone);
@@ -16,6 +17,7 @@ public class CreateWorkspaceRequestValidator
{
public CreateWorkspaceRequestValidator()
{
RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
@@ -38,12 +40,21 @@ public class CreateWorkspaceHandler(
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
{
if (!accessScopeService.IsManager(User))
if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool organizationExists = await dbContext.Organizations
.AnyAsync(organization => organization.Id == request.OrganizationId, ct);
if (!organizationExists)
{
AddError(request => request.OrganizationId, "The selected organization does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
string normalizedTimeZone = request.TimeZone.Trim();
@@ -61,6 +72,7 @@ public class CreateWorkspaceHandler(
Workspace workspace = new()
{
Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId,
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
@@ -71,18 +83,7 @@ public class CreateWorkspaceHandler(
dbContext.Workspaces.Add(workspace);
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
[],
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}

View File

@@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -4,6 +4,7 @@ using System.Security.Claims;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -12,6 +13,7 @@ public record WorkspaceMemberDto(
string DisplayName,
string Email,
string? PortraitUrl,
string RelationshipCategory,
IReadOnlyCollection<string> Roles);
public class GetWorkspaceMembersHandler(
@@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string workspaceClaimValue = workspaceId.ToString();
var users = await dbContext.Users
@@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler(
dbContext.UserClaims.Any(claim =>
claim.UserId == candidate.Id &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue))
claim.ClaimValue == workspaceClaimValue) ||
dbContext.OrganizationMemberships.Any(membership =>
membership.UserId == candidate.Id &&
membership.OrganizationId == workspace.OrganizationId) ||
candidate.Id == workspace.OwnerUserId)
.OrderBy(candidate => candidate.Lastname)
.ThenBy(candidate => candidate.Firstname)
.ThenBy(candidate => candidate.Email)
@@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler(
.ToArray(),
ct);
HashSet<Guid> organizationMemberUserIds = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == workspace.OrganizationId)
.Select(membership => membership.UserId)
.ToHashSetAsync(ct);
organizationMemberUserIds.Add(workspace.OwnerUserId);
var members = users
.Select(candidate => new WorkspaceMemberDto(
candidate.Id,
BuildDisplayName(candidate),
candidate.Email ?? string.Empty,
candidate.PortraitUrl,
organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator",
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
.ToList();

View File

@@ -19,6 +19,7 @@ public record ApprovalStepConfigurationDto(
public record WorkspaceDto(
Guid Id,
Guid OrganizationId,
string Name,
string Slug,
string? LogoUrl,
@@ -28,7 +29,27 @@ public record WorkspaceDto(
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt)
{
public static WorkspaceDto FromWorkspace(
Workspace workspace,
IReadOnlyCollection<ApprovalStepConfigurationDto> approvalSteps)
{
return new WorkspaceDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
}
}
internal class GetWorkspacesHandler(
AppDbContext dbContext,
@@ -43,13 +64,9 @@ internal class GetWorkspacesHandler(
public override async Task HandleAsync(CancellationToken ct)
{
var query = dbContext.Workspaces.AsQueryable();
if (!accessScopeService.IsManager(User))
{
var workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
var query = dbContext.Workspaces
.Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id));
var workspaceRows = await query
.OrderBy(workspace => workspace.Name)
@@ -71,18 +88,9 @@ internal class GetWorkspacesHandler(
.ToArray());
var workspaces = workspaceRows
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
workspace.CreatedAt))
.Select(workspace => WorkspaceDto.FromWorkspace(
workspace,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>()))
.ToList();
await SendOkAsync(workspaces, ct);

View File

@@ -73,7 +73,7 @@ public class UpdateWorkspaceHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -154,18 +154,7 @@ public class UpdateWorkspaceHandler(
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
await SendOkAsync(dto, ct);
}