feat: pivot to social media workflow app
This commit is contained in:
11
backend/Modules/Workspaces/Data/Workspace.cs
Normal file
11
backend/Modules/Workspaces/Data/Workspace.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Socialize.Modules.Workspaces.Data;
|
||||
|
||||
public class Workspace
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string TimeZone { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/Workspaces/Data/WorkspaceInvite.cs
Normal file
12
backend/Modules/Workspaces/Data/WorkspaceInvite.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Socialize.Modules.Workspaces.Data;
|
||||
|
||||
public class WorkspaceInvite
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public required string Role { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public Guid InvitedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
16
backend/Modules/Workspaces/DependencyInjection.cs
Normal file
16
backend/Modules/Workspaces/DependencyInjection.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
using Socialize.Infrastructure.Development;
|
||||
|
||||
namespace Socialize.Modules.Workspaces;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddWorkspaceModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<DevelopmentSeedOptions>(
|
||||
builder.Configuration.GetSection(DevelopmentSeedOptions.SectionName));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
80
backend/Modules/Workspaces/Handlers/CreateWorkspace.cs
Normal file
80
backend/Modules/Workspaces/Handlers/CreateWorkspace.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
100
backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs
Normal file
100
backend/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs
Normal file
49
backend/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
96
backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs
Normal file
96
backend/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
46
backend/Modules/Workspaces/Handlers/GetWorkspaces.cs
Normal file
46
backend/Modules/Workspaces/Handlers/GetWorkspaces.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user