feat: pivot to social media workflow app
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-04-24 12:58:35 -04:00
parent 0f4acc1b6d
commit df3e602015
349 changed files with 18685 additions and 16010 deletions

View File

@@ -0,0 +1,80 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Workspaces.Data;
namespace Socialize.Modules.Workspaces.Handlers;
public record CreateWorkspaceRequest(
string Name,
string Slug,
string TimeZone);
public class CreateWorkspaceRequestValidator
: Validator<CreateWorkspaceRequest>
{
public CreateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
.MaximumLength(128)
.Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$");
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
}
}
public class CreateWorkspaceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateWorkspaceRequest, WorkspaceDto>
{
public override void Configure()
{
Post("/api/workspaces");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
{
if (!accessScopeService.IsManager(User))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
string normalizedTimeZone = request.TimeZone.Trim();
bool duplicateWorkspace = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Slug == normalizedSlug, ct);
if (duplicateWorkspace)
{
AddError(request => request.Slug, "A workspace with this slug already exists.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Workspace workspace = new()
{
Id = Guid.NewGuid(),
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
TimeZone = normalizedTimeZone,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.TimeZone,
workspace.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,100 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Workspaces.Data;
namespace Socialize.Modules.Workspaces.Handlers;
public record CreateWorkspaceInviteRequest(
string Email,
string Role);
public class CreateWorkspaceInviteRequestValidator
: Validator<CreateWorkspaceInviteRequest>
{
private static readonly string[] AllowedRoles =
[
KnownRoles.Client,
KnownRoles.Provider,
KnownRoles.WorkspaceMember,
];
public CreateWorkspaceInviteRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role));
}
}
public class CreateWorkspaceInviteHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateWorkspaceInviteRequest, WorkspaceInviteDto>
{
public override void Configure()
{
Post("/api/workspaces/{workspaceId:guid}/invites");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(CreateWorkspaceInviteRequest request, CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == workspaceId, ct);
if (!workspaceExists)
{
AddError("workspaceId", "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
string normalizedRole = request.Role.Trim();
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(
invite => invite.WorkspaceId == workspaceId &&
invite.Email == normalizedEmail &&
invite.Status == "Pending",
ct);
if (duplicateInvite)
{
AddError(request => request.Email, "A pending invite already exists for this email in the selected workspace.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
WorkspaceInvite invite = new()
{
Id = Guid.NewGuid(),
WorkspaceId = workspaceId,
Email = normalizedEmail,
Role = normalizedRole,
Status = "Pending",
InvitedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.WorkspaceInvites.Add(invite);
await dbContext.SaveChangesAsync(ct);
await SendAsync(
new WorkspaceInviteDto(
invite.Id,
invite.WorkspaceId,
invite.Email,
invite.Role,
invite.Status,
invite.CreatedAt),
StatusCodes.Status201Created,
ct);
}
}

View File

@@ -0,0 +1,49 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Workspaces.Data;
namespace Socialize.Modules.Workspaces.Handlers;
public record WorkspaceInviteDto(
Guid Id,
Guid WorkspaceId,
string Email,
string Role,
string Status,
DateTimeOffset CreatedAt);
public class GetWorkspaceInvitesHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceInviteDto>>
{
public override void Configure()
{
Get("/api/workspaces/{workspaceId:guid}/invites");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
{
await SendForbiddenAsync(ct);
return;
}
List<WorkspaceInviteDto> invites = await dbContext.WorkspaceInvites
.Where(invite => invite.WorkspaceId == workspaceId)
.OrderByDescending(invite => invite.CreatedAt)
.Select(invite => new WorkspaceInviteDto(
invite.Id,
invite.WorkspaceId,
invite.Email,
invite.Role,
invite.Status,
invite.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(invites, ct);
}
}

View File

@@ -0,0 +1,96 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Workspaces.Handlers;
public record WorkspaceMemberDto(
Guid Id,
string DisplayName,
string Email,
string? PortraitUrl,
IReadOnlyCollection<string> Roles);
public class GetWorkspaceMembersHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceMemberDto>>
{
public override void Configure()
{
Get("/api/workspaces/{workspaceId:guid}/members");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string workspaceClaimValue = workspaceId.ToString();
List<User> users = await dbContext.Users
.Where(candidate =>
dbContext.UserClaims.Any(claim =>
claim.UserId == candidate.Id &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue))
.OrderBy(candidate => candidate.Lastname)
.ThenBy(candidate => candidate.Firstname)
.ThenBy(candidate => candidate.Email)
.ToListAsync(ct);
List<Guid> userIds = users
.Select(candidate => candidate.Id)
.ToList();
Dictionary<Guid, IReadOnlyCollection<string>> rolesByUserId = await dbContext.UserRoles
.Where(candidate => userIds.Contains(candidate.UserId))
.Join(
dbContext.Roles,
userRole => userRole.RoleId,
role => role.Id,
(userRole, role) => new { userRole.UserId, role.Name })
.GroupBy(candidate => candidate.UserId)
.ToDictionaryAsync(
group => group.Key,
group => (IReadOnlyCollection<string>)group
.Select(candidate => candidate.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Cast<string>()
.OrderBy(name => name)
.ToArray(),
ct);
List<WorkspaceMemberDto> members = users
.Select(candidate => new WorkspaceMemberDto(
candidate.Id,
BuildDisplayName(candidate),
candidate.Email ?? string.Empty,
candidate.PortraitUrl,
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
.ToList();
await SendOkAsync(members, 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,46 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Workspaces.Data;
namespace Socialize.Modules.Workspaces.Handlers;
public record WorkspaceDto(
Guid Id,
string Name,
string Slug,
string TimeZone,
DateTimeOffset CreatedAt);
public class GetWorkspacesHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
{
public override void Configure()
{
Get("/api/workspaces");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
List<WorkspaceDto> workspaces = await query
.OrderBy(workspace => workspace.Name)
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.TimeZone,
workspace.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(workspaces, ct);
}
}