chore: moving towards agentic development
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 21:12:26 -04:00
parent df3e602015
commit b6eb692c27
179 changed files with 2880 additions and 866 deletions

View File

@@ -0,0 +1,13 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalDecision
{
public Guid Id { get; init; }
public Guid ApprovalRequestId { get; set; }
public required string Decision { get; set; }
public string? Comment { get; set; }
public Guid? DecidedByUserId { get; set; }
public required string DecidedByName { get; set; }
public required string DecidedByEmail { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalRequest
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }
public Guid RequestedByUserId { get; set; }
public DateTimeOffset? DueAt { get; set; }
public required string State { get; set; }
public required string AccessToken { get; set; }
public DateTimeOffset SentAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Approvals.Data;
namespace Socialize.Modules.Approvals;
public static class DependencyInjection
{
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
DateTimeOffset? DueAt);
public class CreateApprovalRequestRequestValidator
: Validator<CreateApprovalRequestRequest>
{
public CreateApprovalRequestRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
}
}
public class CreateApprovalRequestHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
ApprovalRequest approval = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
Stage = request.Stage.Trim(),
ReviewerName = request.ReviewerName.Trim(),
ReviewerEmail = request.ReviewerEmail.Trim(),
RequestedByUserId = User.GetUserId(),
DueAt = request.DueAt,
State = "Pending",
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
SentAt = DateTimeOffset.UtcNow,
};
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.requested",
"ApprovalRequest",
approval.Id,
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
null,
approval.ReviewerEmail,
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
ct);
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
[]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,117 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Approvals.Handlers;
public record GetApprovalsRequest(Guid ContentItemId);
public record ApprovalDecisionDto(
Guid Id,
Guid ApprovalRequestId,
string Decision,
string? Comment,
Guid? DecidedByUserId,
string DecidedByName,
string DecidedByEmail,
string? DecidedByPortraitUrl,
DateTimeOffset CreatedAt);
public record ApprovalRequestDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
Guid RequestedByUserId,
DateTimeOffset? DueAt,
string State,
string AccessToken,
DateTimeOffset SentAt,
DateTimeOffset? CompletedAt,
IReadOnlyCollection<ApprovalDecisionDto> Decisions);
public class GetApprovalsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
{
public override void Configure()
{
Get("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt)
.ToListAsync(ct);
List<Guid> approvalIds = approvals
.Select(approval => approval.Id)
.ToList();
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(decision => approvalIds.Contains(decision.ApprovalRequestId))
.OrderByDescending(decision => decision.CreatedAt)
.ToListAsync(ct);
List<Guid> decidedByUserIds = decisions
.Where(decision => decision.DecidedByUserId.HasValue)
.Select(decision => decision.DecidedByUserId!.Value)
.Distinct()
.ToList();
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
.Where(user => decidedByUserIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<ApprovalRequestDto> dtos = approvals
.Select(approval => new ApprovalRequestDto(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
decisions
.Where(decision => decision.ApprovalRequestId == approval.Id)
.Select(decision => new ApprovalDecisionDto(
decision.Id,
decision.ApprovalRequestId,
decision.Decision,
decision.Comment,
decision.DecidedByUserId,
decision.DecidedByName,
decision.DecidedByEmail,
decision.DecidedByUserId.HasValue
? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value)
: null,
decision.CreatedAt))
.ToList()))
.ToList();
await SendOkAsync(dtos, ct);
}
}

View File

@@ -0,0 +1,169 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
string? Comment,
string? ReviewerName,
string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest>
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
}
}
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals/{id}/decisions");
AllowAnonymous();
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (approval is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
string decidedByEmail = User?.Identity?.IsAuthenticated == true
? User.GetEmail()
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
ApprovalDecision decision = new()
{
Id = Guid.NewGuid(),
ApprovalRequestId = approval.Id,
Decision = normalizedDecision,
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail,
CreatedAt = DateTimeOffset.UtcNow,
};
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
if (approval.Stage == "Internal")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Ready for client review",
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
null,
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
.OrderByDescending(candidate => candidate.CreatedAt)
.ToListAsync(ct);
List<Guid> decidedByUserIds = decisions
.Where(candidate => candidate.DecidedByUserId.HasValue)
.Select(candidate => candidate.DecidedByUserId!.Value)
.Distinct()
.ToList();
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
.Where(user => decidedByUserIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<ApprovalDecisionDto> decisionDtos = decisions
.Select(candidate => new ApprovalDecisionDto(
candidate.Id,
candidate.ApprovalRequestId,
candidate.Decision,
candidate.Comment,
candidate.DecidedByUserId,
candidate.DecidedByName,
candidate.DecidedByEmail,
candidate.DecidedByUserId.HasValue
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
: null,
candidate.CreatedAt))
.ToList();
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
decisionDtos);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.Assets.Data;
public class Asset
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string AssetType { get; set; }
public required string SourceType { get; set; }
public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; }
public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Modules.Assets.Data;
public class AssetRevision
{
public Guid Id { get; init; }
public Guid AssetId { get; set; }
public int RevisionNumber { get; set; }
public required string SourceReference { get; set; }
public string? PreviewUrl { get; set; }
public string? Notes { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Assets.Data;
namespace Socialize.Modules.Assets;
public static class DependencyInjection
{
public static WebApplicationBuilder AddAssetsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,102 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
public record CreateAssetRevisionRequest(
string SourceReference,
string? PreviewUrl,
string? Notes);
public class CreateAssetRevisionRequestValidator
: Validator<CreateAssetRevisionRequest>
{
public CreateAssetRevisionRequestValidator()
{
RuleFor(x => x.SourceReference).NotEmpty().MaximumLength(2048);
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
RuleFor(x => x.Notes).MaximumLength(1024);
}
}
public class CreateAssetRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
{
public override void Configure()
{
Post("/api/assets/{id}/revisions");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CreateAssetRevisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (asset is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null &&
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
int revisionNumber = asset.CurrentRevisionNumber + 1;
asset.CurrentRevisionNumber = revisionNumber;
asset.PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? asset.PreviewUrl : request.PreviewUrl.Trim();
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = revisionNumber,
SourceReference = request.SourceReference.Trim(),
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
if (contentItem is not null)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.revision.created",
"AssetRevision",
revision.Id,
$"A new asset revision was added to {asset.DisplayName}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"revisionNumber":"{{revisionNumber}}"}"""),
ct);
}
AssetRevisionDto dto = new(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,130 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
public record CreateGoogleDriveAssetRequest(
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string DisplayName,
string GoogleDriveFileId,
string GoogleDriveLink,
string? PreviewUrl);
public class CreateGoogleDriveAssetRequestValidator
: Validator<CreateGoogleDriveAssetRequest>
{
public CreateGoogleDriveAssetRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(256);
RuleFor(x => x.GoogleDriveFileId).NotEmpty().MaximumLength(256);
RuleFor(x => x.GoogleDriveLink).NotEmpty().MaximumLength(2048);
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
}
}
public class CreateGoogleDriveAssetHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
{
public override void Configure()
{
Post("/api/assets/google-drive");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CreateGoogleDriveAssetRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
Asset asset = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
AssetType = request.AssetType.Trim(),
SourceType = "GoogleDrive",
DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(),
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = 1,
SourceReference = asset.GoogleDriveLink,
PreviewUrl = asset.PreviewUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Assets.Add(asset);
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.google-drive-linked",
"Asset",
asset.Id,
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
null,
null,
$$"""{"googleDriveFileId":"{{asset.GoogleDriveFileId}}"}"""),
ct);
AssetDto dto = new(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
[
new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt)
]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,89 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Assets.Handlers;
public record GetAssetsRequest(Guid ContentItemId);
public record AssetRevisionDto(
Guid Id,
Guid AssetId,
int RevisionNumber,
string SourceReference,
string? PreviewUrl,
string? Notes,
Guid? CreatedByUserId,
DateTimeOffset CreatedAt);
public record AssetDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string SourceType,
string DisplayName,
string? GoogleDriveFileId,
string? GoogleDriveLink,
string? PreviewUrl,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt,
IReadOnlyCollection<AssetRevisionDto> Revisions);
public class GetAssetsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
{
public override void Configure()
{
Get("/api/assets");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.ContentItemId == request.ContentItemId)
.OrderBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
dbContext.AssetRevisions
.Where(revision => revision.AssetId == asset.Id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt))
.ToList()))
.ToListAsync(ct);
await SendOkAsync(assets, ct);
}
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Modules.Clients.Data;
public class Client
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string Status { get; set; }
public string? PortraitUrl { get; set; }
public string? PrimaryContactName { get; set; }
public string? PrimaryContactEmail { get; set; }
public string? PrimaryContactPortraitUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients;
public static class DependencyInjection
{
public static WebApplicationBuilder AddClientsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,65 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record ChangeClientPortraitRequest(
IFormFile File);
public record ChangeClientPortraitResponse(
string BlobUrl);
public sealed class ChangeClientPortraitRequestValidator : Validator<ChangeClientPortraitRequest>
{
public ChangeClientPortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
public class ChangeClientPortraitHandler(
AppDbContext clientsDbContext,
IBlobStorage blobStorage,
AccessScopeService accessScopeService)
: Endpoint<ChangeClientPortraitRequest, ChangeClientPortraitResponse>
{
public override void Configure()
{
Post("/api/clients/{id}/portrait");
Options(o => o.WithTags("Clients"));
AllowFileUploads();
}
public override async Task HandleAsync(ChangeClientPortraitRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (client is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Clients,
$"{client.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
client.PortraitUrl = blobUrl;
await clientsDbContext.SaveChangesAsync(ct);
await SendOkAsync(new ChangeClientPortraitResponse(blobUrl), ct);
}
}

View File

@@ -0,0 +1,101 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Clients.Handlers;
public record CreateClientRequest(
Guid WorkspaceId,
string Name,
string? PortraitUrl,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class CreateClientRequestValidator
: Validator<CreateClientRequest>
{
public CreateClientRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
}
}
public class CreateClientHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateClientRequest, ClientDto>
{
public override void Configure()
{
Post("/api/clients");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
bool duplicateClient = await dbContext.Clients
.AnyAsync(
client => client.WorkspaceId == request.WorkspaceId && client.Name == normalizedName,
ct);
if (duplicateClient)
{
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Client client = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
Name = normalizedName,
Status = "Active",
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName,
PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail,
PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Clients.Add(client);
await dbContext.SaveChangesAsync(ct);
ClientDto dto = new(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,73 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record GetClientsRequest(Guid? WorkspaceId);
public record ClientDto(
Guid Id,
Guid WorkspaceId,
string Name,
string Status,
string? PortraitUrl,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class GetClientsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetClientsRequest, IReadOnlyCollection<ClientDto>>
{
public override void Configure()
{
Get("/api/clients");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(GetClientsRequest request, CancellationToken ct)
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
List<ClientDto> clients = await query
.OrderBy(client => client.Name)
.Select(client => new ClientDto(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl))
.ToListAsync(ct);
await SendOkAsync(clients, ct);
}
}

View File

@@ -0,0 +1,98 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record UpdateClientRequest(
string Name,
string? PortraitUrl,
string Status,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class UpdateClientRequestValidator
: Validator<UpdateClientRequest>
{
public UpdateClientRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
}
}
public class UpdateClientHandler(
AppDbContext clientsDbContext,
AccessScopeService accessScopeService)
: Endpoint<UpdateClientRequest, ClientDto>
{
public override void Configure()
{
Put("/api/clients/{id}");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(UpdateClientRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (client is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedStatus = request.Status.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
bool duplicateClient = await clientsDbContext.Clients
.AnyAsync(
candidate => candidate.Id != id
&& candidate.WorkspaceId == client.WorkspaceId
&& candidate.Name == normalizedName,
ct);
if (duplicateClient)
{
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
client.Name = normalizedName;
client.Status = normalizedStatus;
client.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
client.PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName;
client.PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail;
client.PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl;
await clientsDbContext.SaveChangesAsync(ct);
ClientDto dto = new(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.Comments.Data;
public class Comment
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public Guid? ParentCommentId { get; set; }
public Guid AuthorUserId { get; set; }
public required string AuthorDisplayName { get; set; }
public required string AuthorEmail { get; set; }
public required string Body { get; set; }
public bool IsResolved { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Comments.Data;
namespace Socialize.Modules.Comments;
public static class DependencyInjection
{
public static WebApplicationBuilder AddCommentsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public record CreateCommentRequest(
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
string Body);
public class CreateCommentRequestValidator
: Validator<CreateCommentRequest>
{
public CreateCommentRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
}
}
public class CreateCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto>
{
public override void Configure()
{
Post("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
if (request.ParentCommentId.HasValue)
{
bool parentExists = await dbContext.Comments
.AnyAsync(
comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId,
ct);
if (!parentExists)
{
AddError(request => request.ParentCommentId, "The selected parent comment does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
Comment comment = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
Body = request.Body.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Comments.Add(comment);
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.created",
"Comment",
comment.Id,
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
null,
null,
$$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,80 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Comments.Handlers;
public record GetCommentsRequest(Guid ContentItemId);
public record CommentDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
Guid AuthorUserId,
string AuthorDisplayName,
string AuthorEmail,
string? AuthorPortraitUrl,
string Body,
bool IsResolved,
DateTimeOffset CreatedAt,
DateTimeOffset? ResolvedAt);
public class GetCommentsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetCommentsRequest, IReadOnlyCollection<CommentDto>>
{
public override void Configure()
{
Get("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(GetCommentsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<Comment> comments = await dbContext.Comments
.Where(comment => comment.ContentItemId == request.ContentItemId)
.OrderBy(comment => comment.CreatedAt)
.ToListAsync(ct);
List<Guid> authorIds = comments
.Select(comment => comment.AuthorUserId)
.Distinct()
.ToList();
Dictionary<Guid, string?> authorPortraits = await dbContext.Users
.Where(user => authorIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<CommentDto> dtos = comments
.Select(comment => new CommentDto(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraits.GetValueOrDefault(comment.AuthorUserId),
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt))
.ToList();
await SendOkAsync(dtos, ct);
}
}

View File

@@ -0,0 +1,84 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public class ResolveCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: EndpointWithoutRequest<CommentDto>
{
public override void Configure()
{
Post("/api/comments/{id}/resolve");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (comment is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
if (!canResolve)
{
await SendForbiddenAsync(ct);
return;
}
comment.IsResolved = true;
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.resolved",
"Comment",
comment.Id,
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
null,
null,
null),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,18 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItem
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; }
public Guid ProjectId { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public required string Status { get; set; }
public DateTimeOffset? DueDate { get; set; }
public required string CurrentRevisionLabel { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItemRevision
{
public Guid Id { get; init; }
public Guid ContentItemId { get; set; }
public int RevisionNumber { get; set; }
public required string RevisionLabel { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public string? ChangeSummary { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems;
public static class DependencyInjection
{
public static WebApplicationBuilder AddContentItemsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,148 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRequest(
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
DateTimeOffset? DueDate);
public class CreateContentItemRequestValidator
: Validator<CreateContentItemRequest>
{
public CreateContentItemRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
}
}
public class CreateContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRequest, ContentItemDto>
{
public override void Configure()
{
Post("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool clientExists = await dbContext.Clients
.AnyAsync(
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
ct);
if (!clientExists)
{
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool projectExists = await dbContext.Projects
.AnyAsync(
project => project.Id == request.ProjectId &&
project.WorkspaceId == request.WorkspaceId &&
project.ClientId == request.ClientId,
ct);
if (!projectExists)
{
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
ContentItem item = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId,
ProjectId = request.ProjectId,
Title = request.Title.Trim(),
PublicationMessage = request.PublicationMessage.Trim(),
PublicationTargets = request.PublicationTargets.Trim(),
Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(),
Status = "Draft",
DueDate = request.DueDate,
CurrentRevisionLabel = "v1",
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItems.Add(item);
dbContext.ContentItemRevisions.Add(new ContentItemRevision
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
RevisionNumber = 1,
RevisionLabel = "v1",
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
CreatedAt = DateTimeOffset.UtcNow,
});
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.created",
"ContentItem",
item.Id,
$"Content item {item.Title} was created.",
null,
null,
$$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""),
ct);
ContentItemDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRevisionRequest(
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary);
public class CreateContentItemRevisionRequestValidator
: Validator<CreateContentItemRevisionRequest>
{
public CreateContentItemRevisionRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
RuleFor(x => x.ChangeSummary).MaximumLength(1024);
}
}
public class CreateContentItemRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
{
public override void Configure()
{
Post("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRevisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
int revisionNumber = item.CurrentRevisionNumber + 1;
string revisionLabel = $"v{revisionNumber}";
item.Title = request.Title.Trim();
item.PublicationMessage = request.PublicationMessage.Trim();
item.PublicationTargets = request.PublicationTargets.Trim();
item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim();
item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new()
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
RevisionNumber = revisionNumber,
RevisionLabel = revisionLabel,
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
ChangeSummary = string.IsNullOrWhiteSpace(request.ChangeSummary) ? null : request.ChangeSummary.Trim(),
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItemRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.revision.created",
"ContentItemRevision",
revision.Id,
$"Revision {revisionLabel} was created for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"revisionLabel":"{{revisionLabel}}","status":"{{item.Status}}"}"""),
ct);
ContentItemRevisionDto dto = new(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,68 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemDetailDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt);
public class GetContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<ContentItemDetailDto>
{
public override void Configure()
{
Get("/api/content-items/{id}");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItemDetailDto? item = await dbContext.ContentItems
.Where(candidate => candidate.Id == id)
.Select(candidate => new ContentItemDetailDto(
candidate.Id,
candidate.WorkspaceId,
candidate.ClientId,
candidate.ProjectId,
candidate.Title,
candidate.PublicationMessage,
candidate.PublicationTargets,
candidate.Hashtags,
candidate.Status,
candidate.DueDate,
candidate.CurrentRevisionLabel,
candidate.CurrentRevisionNumber,
candidate.CreatedAt))
.SingleOrDefaultAsync(ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
await SendOkAsync(item, ct);
}
}

View File

@@ -0,0 +1,64 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemRevisionDto(
Guid Id,
Guid ContentItemId,
int RevisionNumber,
string RevisionLabel,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary,
Guid? CreatedByUserId,
DateTimeOffset CreatedAt);
public class GetContentItemRevisionsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<ContentItemRevisionDto>>
{
public override void Configure()
{
Get("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<ContentItemRevisionDto> revisions = await dbContext.ContentItemRevisions
.Where(revision => revision.ContentItemId == id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new ContentItemRevisionDto(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(revisions, ct);
}
}

View File

@@ -0,0 +1,91 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
public record ContentItemDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber);
public class GetContentItemsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetContentItemsRequest, IReadOnlyCollection<ContentItemDto>>
{
public override void Configure()
{
Get("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct)
{
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
}
if (projectScopeIds.Count > 0)
{
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
}
if (request.ProjectId.HasValue)
{
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
}
if (request.ClientId.HasValue)
{
query = query.Where(item => item.ClientId == request.ClientId.Value);
}
List<ContentItemDto> items = await query
.OrderBy(item => item.DueDate)
.ThenBy(item => item.Title)
.Select(item => new ContentItemDto(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber))
.ToListAsync(ct);
await SendOkAsync(items, ct);
}
}

View File

@@ -0,0 +1,105 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record UpdateContentItemStatusRequest(string Status);
public class UpdateContentItemStatusRequestValidator
: Validator<UpdateContentItemStatusRequest>
{
public UpdateContentItemStatusRequestValidator()
{
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
}
}
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{
private static readonly HashSet<string> AllowedStatuses =
[
"Draft",
"In internal review",
"Changes requested internally",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"Approved",
"Rejected",
"Ready to publish",
"Published",
"Archived",
];
public override void Configure()
{
Post("/api/content-items/{id}/status");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedStatus = request.Status.Trim();
if (!AllowedStatuses.Contains(normalizedStatus))
{
AddError(request => request.Status, "The requested status is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
item.Status = normalizedStatus;
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.status.updated",
"ContentItem",
item.Id,
$"Status changed to {item.Status} for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"status":"{{item.Status}}"}"""),
ct);
ContentItemDetailDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber,
item.CreatedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Modules.Identity.Configuration;
public record JwtOptions
{
public const string SectionName = "Authentication:Jwt";
public required TimeSpan Lifetime { get; init; }
public required string Issuer { get; init; }
public required string Audience { get; init; }
public required string Key { get; init; }
public TimeSpan RefreshTokenLifetime { get; init; }
public bool RefreshTokenRequireRotation { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Modules.Identity.Contracts;
public interface IUserLookup
{
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Modules.Identity.Contracts;
public static class KnownRoles
{
public const string Administrator = nameof(Administrator);
public const string Manager = nameof(Manager);
public const string Client = nameof(Client);
public const string Provider = nameof(Provider);
public const string WorkspaceMember = nameof(WorkspaceMember);
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Modules.Identity.Contracts;
public record UserReference(
Guid Id,
string Fullname,
string? PortraitUrl);

View File

@@ -0,0 +1,88 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Models;
namespace Socialize.Modules.Identity.Data;
public class IdentityService(
UserManager userManager,
IHttpContextAccessor contextAccessor
)
{
public async Task<UserModel?> GetCurrentUserAsync()
{
string? currentUserId = contextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(currentUserId))
{
return null;
}
UserModel? ret;
User? user = await userManager.FindByIdAsync(currentUserId);
if (user == null)
{
ret = null;
}
else
{
UserModel userModel = new()
{
Id = user.Id,
Username = user.UserName ?? string.Empty,
PhoneNumber = user.PhoneNumber ?? string.Empty,
Email = user.Email ?? string.Empty,
PortraitUrl = user.PortraitUrl,
Alias = user.Alias,
Firstname = user.Firstname,
Lastname = user.Lastname,
BirthDate = user.BirthDate,
Address = user.Address
};
ret = userModel;
}
return ret;
}
public async Task<IList<string>> GetCurrentUserRolesAsync()
{
UserModel? currentUserModel = await GetCurrentUserAsync();
if (currentUserModel is null)
{
return [];
}
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
if (currentUser is null)
{
return [];
}
IList<string> userRoles = await userManager.GetRolesAsync(currentUser);
return userRoles;
}
public async Task<IList<Claim>> GetCurrentUserClaimsAsync()
{
UserModel? currentUserModel = await GetCurrentUserAsync();
if (currentUserModel is null)
{
return [];
}
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
if (currentUser is null)
{
return [];
}
return await userManager.GetClaimsAsync(currentUser);
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Data;
public class Role : IdentityRole<Guid>
{
public Role() { }
public Role(string roleName) : base(roleName) { }
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Data;
public class User : IdentityUser<Guid>
{
[MaxLength(256)] public string? Alias { get; set; }
[MaxLength(256)] public string? Firstname { get; set; }
[MaxLength(256)] public string? Lastname { get; set; }
public DateTime? BirthDate { get; set; }
[MaxLength(256)] public string? Address { get; set; }
[MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public string Fullname => $"{Lastname}, {Firstname}";
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Data;
public sealed class UserManager(
IUserStore<User> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<User> passwordHasher,
IEnumerable<IUserValidator<User>> userValidators,
IEnumerable<IPasswordValidator<User>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<User>> logger)
: UserManager<User>(
store,
optionsAccessor,
passwordHasher,
userValidators,
passwordValidators,
keyNormalizer,
errors,
services,
logger)
{
}

View File

@@ -0,0 +1,101 @@
using Socialize.Data;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity;
public static class DependencyInjection
{
public static WebApplicationBuilder AddIdentityModule(
this WebApplicationBuilder builder)
{
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddAuthorizationBuilder();
builder.Services
.Configure<IdentityOptions>(options =>
{
if (!builder.Environment.IsDevelopment())
{
return;
}
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 3;
options.Password.RequiredUniqueChars = 1;
})
.AddIdentityCore<User>()
.AddUserManager<UserManager>()
.AddRoles<Role>()
.AddEntityFrameworkStores<AppDbContext>()
.AddApiEndpoints()
.AddDefaultTokenProviders();
// Singleton services
builder.Services.AddSingleton(TimeProvider.System);
// Scoped services
builder.Services.AddScoped<IdentityService>();
builder.Services.AddScoped<EmailVerificationService>();
builder.Services.AddScoped<AccessTokenFactory>();
builder.Services.AddScoped<IUserLookup, UserLookup>();
return builder;
}
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using IServiceScope scope = scopeFactory.CreateScope();
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
await TrySeedAsync(roleManager);
return app;
}
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
{
Role administratorRole = new(KnownRoles.Administrator);
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
{
await roleManager.CreateAsync(administratorRole);
}
Role managerRole = new(KnownRoles.Manager);
if (roleManager.Roles.All(r => r.Name != managerRole.Name))
{
await roleManager.CreateAsync(managerRole);
}
Role clientRole = new(KnownRoles.Client);
if (roleManager.Roles.All(r => r.Name != clientRole.Name))
{
await roleManager.CreateAsync(clientRole);
}
Role providerRole = new(KnownRoles.Provider);
if (roleManager.Roles.All(r => r.Name != providerRole.Name))
{
await roleManager.CreateAsync(providerRole);
}
Role workspaceMemberRole = new(KnownRoles.WorkspaceMember);
if (roleManager.Roles.All(r => r.Name != workspaceMemberRole.Name))
{
await roleManager.CreateAsync(workspaceMemberRole);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
string? Address);
[PublicAPI]
public class ChangeAddressHandler(
UserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()
{
Post("/api/users/address");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAddressRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Address = request.Address;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAliasRequest(
string? Alias);
[PublicAPI]
public class ChangeAliasHandler(
UserManager userManager)
: Endpoint<ChangeAliasRequest>
{
public override void Configure()
{
Post("/api/users/alias");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAliasRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Alias = request.Alias;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeBirthDateRequest(
DateTime BirthDate);
[PublicAPI]
public class ChangeBirthDateHandler(
UserManager userManager)
: Endpoint<ChangeBirthDateRequest>
{
public override void Configure()
{
Post("/api/users/birthdate");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeBirthDateRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.BirthDate = request.BirthDate;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,48 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeEmailRequest(
string? Email);
[PublicAPI]
public class ChangeEmailHandler(
UserManager userManager)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()
{
Post("/api/users/email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeEmailRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Email = request.Email;
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,49 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeFullnameRequest(
string? Firstname,
string? Lastname);
[PublicAPI]
public class ChangeFullnameHandler(
UserManager userManager)
: Endpoint<ChangeFullnameRequest>
{
public override void Configure()
{
Post("/api/users/fullname");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeFullnameRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Firstname = request.Firstname;
user.Lastname = request.Lastname;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,48 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePhoneRequest(
string? PhoneNumber);
[PublicAPI]
public class ChangePhoneHandler(
UserManager userManager)
: Endpoint<ChangePhoneRequest>
{
public override void Configure()
{
Post("/api/users/phone");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePhoneRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PhoneNumber = request.PhoneNumber;
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,74 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePortraitRequest(
IFormFile File);
[PublicAPI]
public record ChangePortraitResponse(
string BlobUrl);
[PublicAPI]
public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitRequest>
{
public ChangePortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangePortraitHandler(
UserManager userManager,
IBlobStorage blobStorage)
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
{
public override void Configure()
{
Post("/api/users/portrait");
Options(o => o.WithTags("Users"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePortraitRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
user.PortraitUrl = blobUrl;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(
new ChangePortraitResponse(blobUrl),
ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,92 @@
using System.Web;
using Socialize.Infrastructure.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ForgotPasswordRequest(
string Email);
[PublicAPI]
public class ForgotPasswordHandler(
UserManager userManager,
IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> options)
: Endpoint<ForgotPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/forgot-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ForgotPasswordRequest request,
CancellationToken ct)
{
// Find user by email
User? user = await userManager.FindByEmailAsync(request.Email);
// Always return OK even if user not found to prevent email enumeration
if (user is null)
{
await SendOkAsync(ct);
return;
}
// Generate password reset token
string token = await userManager.GeneratePasswordResetTokenAsync(user);
// URL encode the token as it may contain characters that are not URL safe
string encodedToken = HttpUtility.UrlEncode(token);
// Build reset link
string resetLink =
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
// Create a styled email message
string subject = "Reset your Socialize password";
string message = $"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Reset Your Socialize Password</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please click the button below to reset your password:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{resetLink}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Reset Password
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request a password reset, please ignore this email.
</p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href='{resetLink}' style="color: #3498db; word-break: break-all;">{resetLink}</a>
</p>
</div>
""";
// Send email
await emailSender.SendEmailAsync(request.Email, subject, message);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,80 @@
using System.Security.Claims;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Models;
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserQueryHandler(
IdentityService identityService)
: EndpointWithoutRequest<UserDto>
{
public override void Configure()
{
Get("/api/users/profile");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
UserModel? userModel = await identityService.GetCurrentUserAsync();
if (userModel is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
IList<string> roles = await identityService.GetCurrentUserRolesAsync();
IList<Claim> claims = await identityService.GetCurrentUserClaimsAsync();
string? persona = claims
.Where(claim => claim.Type == KnownClaims.Persona)
.Select(claim => claim.Value)
.LastOrDefault();
List<Guid> workspaceIds = claims
.Where(claim => claim.Type == KnownClaims.WorkspaceScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
List<Guid> clientIds = claims
.Where(claim => claim.Type == KnownClaims.ClientScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
List<Guid> projectIds = claims
.Where(claim => claim.Type == KnownClaims.ProjectScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
await SendOkAsync(
new UserDto
{
Id = userModel.Id,
Persona = persona,
AuthorizedWorkspaceIds = workspaceIds,
AuthorizedClientIds = clientIds,
AuthorizedProjectIds = projectIds,
Alias = userModel.Alias,
PortraitUrl = userModel.PortraitUrl,
Firstname = userModel.Firstname,
Lastname = userModel.Lastname,
Username = userModel.Username,
PhoneNumber = userModel.PhoneNumber,
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
UserRoles = roles
},
cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Models;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserPortraitHandler(
IdentityService identityService,
IBlobStorage blobStorage
)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/users/portrait");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
UserModel? identityUser = await identityService.GetCurrentUserAsync();
if (identityUser is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
MemoryStream stream = await blobStorage.DownloadFileAsync(
ContainerNames.Users,
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
cancellationToken);
await SendOkAsync(stream, cancellationToken);
}
}

View File

@@ -0,0 +1,82 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Services;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record LoginRequest(
string Email,
string Password);
[PublicAPI]
public record LoginResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginRequest, LoginResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginRequest request,
CancellationToken ct)
{
// Find the user by email
User? user = await userManager.FindByEmailAsync(request.Email);
user ??= await userManager.FindByNameAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Verify password
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
if (!isPasswordValid)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Check if the email is confirmed
if (!user.EmailConfirmed)
{
await SendStringAsync(
"Email not verified. Please check your email for verification instructions.",
401,
cancellation: ct);
return;
}
// Generate a new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate JWT token
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class FacebookUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public string? Email { get; init; } // Email might be null if not granted
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("picture")] public required FacebookPictureData Picture { get; init; }
}
[PublicAPI]
public class FacebookPictureData
{
[JsonPropertyName("data")] public required FacebookPicture Picture { get; init; }
}
[PublicAPI]
public class FacebookPicture
{
[JsonPropertyName("url")] public required string Url { get; init; }
}
[PublicAPI]
public record LoginWithFacebookRequest(
string Token);
[PublicAPI]
public record LoginWithFacebookResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithFacebookHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginWithFacebookRequest, LoginWithFacebookResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-facebook");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithFacebookRequest request,
CancellationToken ct)
{
// Verify the token with Facebook
using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync(
$"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, profile picture)
string content = await response.Content.ReadAsStringAsync(ct);
FacebookUserInfo? userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
{
await SendStringAsync(
"Failed to retrieve user information from Facebook",
400,
cancellation: ct);
return;
}
// Check if user exists or create a new one
User? user = await userManager.FindByEmailAsync(userInfo.Email!);
if (user is null)
{
string generatedPassword = PasswordGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "",
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture.Picture.Url,
FacebookId = userInfo.Id // Storing Facebook ID
};
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate refresh token
string refreshToken = RefreshTokenGenerator.Next();
// Store refresh token in user's properties
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginWithFacebookResponse(accessToken, refreshToken),
ct);
}
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
internal class GoogleToken
{
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
[JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; }
[JsonPropertyName("scope")] public required string Scope { get; init; }
[JsonPropertyName("authuser")] public required string AuthUser { get; init; }
[JsonPropertyName("prompt")] public required string Prompt { get; init; }
}
public class GoogleUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public required string Email { get; init; }
[JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("given_name")] public required string GivenName { get; init; }
[JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty;
[JsonPropertyName("picture")] public required string Picture { get; init; }
}
[PublicAPI]
public record LoginWithGoogleRequest(
string Token);
[PublicAPI]
public record LoginWithGoogleResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithGoogleHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginWithGoogleRequest, LoginWithGoogleResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-google");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithGoogleRequest request,
CancellationToken ct)
{
GoogleToken googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google
using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, etc.).
string content = await response.Content.ReadAsStringAsync(ct);
GoogleUserInfo? userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null
|| !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email))
{
await SendStringAsync(
"The token does not contain an email",
400,
cancellation: ct);
return;
}
// Check if the user exists or create a new one
User? user = await userManager.FindByEmailAsync(userInfo.Email);
if (user is null)
{
string generatedPassword = PasswordGenerator.Next();
string refreshToken = RefreshTokenGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email,
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName,
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture,
GoogleId = userInfo.Id,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
};
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate the new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginWithGoogleResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,63 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record RefreshTokenRequest(
string RefreshToken);
[PublicAPI]
public record RefreshTokenResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class RefreshTokenHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<RefreshTokenRequest, RefreshTokenResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/refresh");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RefreshTokenRequest request,
CancellationToken ct)
{
// Find the user using the refresh token
User? user = await userManager.Users
.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken, ct);
if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
await SendUnauthorizedAsync(ct);
return;
}
// Generate a new refresh token if rotation is required
if (jwtOptions.Value.RefreshTokenRequireRotation || user.RefreshToken is null)
{
user.RefreshToken = RefreshTokenGenerator.Next();
}
// Update refresh token expiry time
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate a new access token
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new RefreshTokenResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,79 @@
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record RegisterRequest(
string Email,
string Password,
string Name);
[PublicAPI]
public record RegisterResponse(
string Message);
[PublicAPI]
public class RegisterHandler(
UserManager userManager,
EmailVerificationService emailVerificationService)
: Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/register");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RegisterRequest request,
CancellationToken ct)
{
// Check if the user already exists
User? existingUser = await userManager.FindByEmailAsync(request.Email);
if (existingUser is not null)
{
await SendStringAsync(
"A user with this email already exists",
400,
cancellation: ct);
return;
}
// Split the name into firstname and lastname (if provided)
string[] nameParts = request.Name.Split(' ', 2);
string firstname = nameParts[0];
string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
// Create a new user
User user = new()
{
UserName = request.Email,
Email = request.Email,
Firstname = firstname,
Lastname = lastname,
Alias = request.Name
};
IdentityResult result = await userManager.CreateAsync(
user,
request.Password);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
await emailVerificationService.SendVerificationEmailAsync(user);
await SendOkAsync(
new RegisterResponse("Registration successful! Please check your email to verify your account."),
ct);
}
}

View File

@@ -0,0 +1,58 @@
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ResendVerificationRequest(
string Email);
[PublicAPI]
public record ResendVerificationResponse(
string Message);
[PublicAPI]
public class ResendVerificationHandler(
EmailVerificationService emailWriter,
UserManager userManager)
: Endpoint<ResendVerificationRequest, ResendVerificationResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/resend-verification");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResendVerificationRequest request,
CancellationToken ct)
{
// Find a user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
// Don't reveal that the user doesn't exist
await SendOkAsync(
new ResendVerificationResponse(
"If your email exists in our system, a verification link has been sent."),
ct);
return;
}
// Check if the email is already confirmed
if (user.EmailConfirmed)
{
await SendOkAsync(
new ResendVerificationResponse("Your email is already verified. You can log in."),
ct);
return;
}
await emailWriter.SendVerificationEmailAsync(user);
await SendOkAsync(
new ResendVerificationResponse("If your email exists in our system, a verification link has been sent."),
ct);
}
}

View File

@@ -0,0 +1,56 @@
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ResetPasswordRequest(
string Email,
string Token,
string NewPassword);
[PublicAPI]
public class ResetPasswordHandler(
UserManager userManager)
: Endpoint<ResetPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/reset-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResetPasswordRequest request,
CancellationToken ct)
{
// Find user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid request",
400,
cancellation: ct);
return;
}
// Reset password with token
IdentityResult result = await userManager.ResetPasswordAsync(
user,
request.Token,
request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid or expired token",
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,51 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record SetPasswordRequest(
string NewPassword);
[PublicAPI]
public class SetPasswordHandler(
UserManager userManager)
: Endpoint<SetPasswordRequest>
{
public override void Configure()
{
Post("/api/users/set-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
SetPasswordRequest request,
CancellationToken ct)
{
// Get current user id from claims
string userId = User.GetUserId().ToString();
// Get user from database
User? user = await userManager.FindByIdAsync(userId);
if (user is null)
{
await SendForbiddenAsync(ct);
return;
}
string resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
IdentityResult result = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,60 @@
using System.Web;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record VerifyEmailRequest(
string UserId,
string Token);
[PublicAPI]
public record VerifyEmailResponse(
string Message);
[PublicAPI]
public class VerifyEmailHandler(
UserManager userManager)
: Endpoint<VerifyEmailRequest, VerifyEmailResponse>
{
public override void Configure()
{
AllowAnonymous();
Get("/api/users/verify-email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
VerifyEmailRequest request,
CancellationToken ct)
{
// Find user by ID
User? user = await userManager.FindByIdAsync(request.UserId);
if (user is null)
{
await SendStringAsync(
"Invalid verification link",
400,
cancellation: ct);
return;
}
// Verify the token and confirm email
string decoded = HttpUtility.UrlDecode(request.Token);
string decodedWithPlus = request.Token.Replace(" ", "+");
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid verification link or the link has expired",
400,
cancellation: ct);
return;
}
await SendOkAsync(
new VerifyEmailResponse("Email verification successful! You can now log in."),
ct);
}
}

View File

@@ -0,0 +1,14 @@
using Socialize.Modules.Identity.Models;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity;
public static class IdentityResultExtensions
{
public static Result ToApplicationResult(this IdentityResult result)
{
return result.Succeeded
? Result.Success()
: Result.Failure(result.Errors.Select(e => e.Description));
}
}

View File

@@ -0,0 +1,49 @@
namespace Socialize.Modules.Identity.Models;
public class Result(
bool succeeded,
IEnumerable<string> errors)
{
public bool Succeeded { get; init; } = succeeded;
public string[] Errors { get; init; } = errors.ToArray();
public static Result Success()
{
return new Result(true, Array.Empty<string>());
}
public static Result Failure(IEnumerable<string> errors)
{
return new Result(false, errors);
}
}
public class Result<T>(
T? value,
bool succeeded,
IEnumerable<string> errors)
{
public bool Succeeded { get; init; } = succeeded;
public string[] Errors { get; init; } = errors.ToArray();
public T? Value { get; set; } = value;
public T GetValueOrDefault()
{
return Value ?? default(T)!;
}
public string GetErrorsAsString()
{
return Errors.Length == 0 ? string.Empty : string.Join(", ", Errors);
}
public static Result<T> Success(T value)
{
return new Result<T>(value, true, Array.Empty<string>());
}
public static Result<T> Failure(T value, IEnumerable<string> errors)
{
return new Result<T>(value, false, errors);
}
}

View File

@@ -0,0 +1,7 @@
namespace Socialize.Modules.Identity.Models;
public class RoleModel
{
public Guid Id { get; set; }
public string? Name { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace Socialize.Modules.Identity.Models;
public class UserDto
{
public Guid Id { get; init; }
public IList<string> UserRoles { get; init; } = [];
public string? Persona { get; init; }
public IList<Guid> AuthorizedWorkspaceIds { get; init; } = [];
public IList<Guid> AuthorizedClientIds { get; init; } = [];
public IList<Guid> AuthorizedProjectIds { get; init; } = [];
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }
public string? Firstname { get; init; }
public string? Lastname { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace Socialize.Modules.Identity.Models;
public class UserModel
{
public Guid Id { get; set; }
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }
public string? Firstname { get; init; }
public string? Lastname { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
}

View File

@@ -0,0 +1,43 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Services;
public sealed class AccessTokenFactory(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
{
public async Task<string> CreateAsync(User user)
{
IList<string> roles = await userManager.GetRolesAsync(user);
IList<Claim> claims = await userManager.GetClaimsAsync(user);
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
return JwtTokenHelper.GenerateJwtToken(
jwtOptions.Value.Lifetime,
jwtOptions.Value.Issuer,
jwtOptions.Value.Audience,
jwtOptions.Value.Key,
user.Id.ToString(),
user.Email ?? string.Empty,
user.Alias,
user.Firstname ?? string.Empty,
user.Lastname ?? string.Empty,
user.PortraitUrl,
roles,
tokenClaims);
}
}

View File

@@ -0,0 +1,61 @@
using System.Web;
using Socialize.Infrastructure.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Services;
[PublicAPI]
public sealed class EmailVerificationService(
IOptionsSnapshot<WebsiteOptions> options,
UserManager userManager,
IEmailSender emailSender)
{
public async Task SendVerificationEmailAsync(
User user)
{
// Generate email confirmation token
string token = await userManager.GenerateEmailConfirmationTokenAsync(user);
string encodedToken = HttpUtility.UrlEncode(token);
string verificationLink = $"{options.Value.FrontendBaseUrl}/verify-email?userId={user.Id}&token={encodedToken}";
// Send verification email
await emailSender.SendEmailAsync(
user.Email!,
"Verify your email address",
$"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Welcome to Socialize!</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please verify your email address by clicking the button below:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{verificationLink}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Verify Email Address
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request this, please ignore this email.
</p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href='{verificationLink}' style="color: #3498db; word-break: break-all;">{verificationLink}</a>
</p>
</div>
""");
}
}

View File

@@ -0,0 +1,21 @@
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
namespace Socialize.Modules.Identity.Services;
public sealed class UserLookup(
UserManager userManager)
: IUserLookup
{
public async Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
User? user = await userManager.FindByIdAsync(userId.ToString());
return user is null
? null
: new UserReference(
user.Id,
user.Fullname,
user.PortraitUrl);
}
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Modules.Notifications.Contracts;
public record NotificationEventWriteModel(
Guid WorkspaceId,
Guid? ContentItemId,
string EventType,
string EntityType,
Guid EntityId,
string Message,
Guid? RecipientUserId,
string? RecipientEmail,
string? MetadataJson);
public interface INotificationEventWriter
{
Task WriteAsync(NotificationEventWriteModel model, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Modules.Notifications.Data;
public class NotificationEvent
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid? ContentItemId { get; set; }
public required string EventType { get; set; }
public required string EntityType { get; set; }
public Guid EntityId { get; set; }
public required string Message { get; set; }
public Guid? RecipientUserId { get; set; }
public string? RecipientEmail { get; set; }
public string? MetadataJson { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ReadAt { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Socialize.Modules.Notifications.Contracts;
using Socialize.Modules.Notifications.Data;
using Socialize.Modules.Notifications.Services;
namespace Socialize.Modules.Notifications;
public static class DependencyInjection
{
public static WebApplicationBuilder AddNotificationsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<INotificationEventWriter, NotificationEventWriter>();
return builder;
}
}

View File

@@ -0,0 +1,88 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Notifications.Handlers;
public record GetNotificationsRequest(Guid? WorkspaceId, Guid? ContentItemId);
public record NotificationEventDto(
Guid Id,
Guid WorkspaceId,
Guid? ContentItemId,
string EventType,
string EntityType,
Guid EntityId,
string Message,
Guid? RecipientUserId,
string? RecipientEmail,
string? MetadataJson,
DateTimeOffset CreatedAt,
DateTimeOffset? ReadAt);
public class GetNotificationsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetNotificationsRequest, IReadOnlyCollection<NotificationEventDto>>
{
public override void Configure()
{
Get("/api/notifications");
Options(o => o.WithTags("Notifications"));
}
public override async Task HandleAsync(GetNotificationsRequest request, CancellationToken ct)
{
if (request.ContentItemId.HasValue)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
}
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value);
}
if (request.ContentItemId.HasValue)
{
query = query.Where(notificationEvent => notificationEvent.ContentItemId == request.ContentItemId.Value);
}
List<NotificationEventDto> notifications = await query
.OrderByDescending(notificationEvent => notificationEvent.CreatedAt)
.Take(100)
.Select(notificationEvent => new NotificationEventDto(
notificationEvent.Id,
notificationEvent.WorkspaceId,
notificationEvent.ContentItemId,
notificationEvent.EventType,
notificationEvent.EntityType,
notificationEvent.EntityId,
notificationEvent.Message,
notificationEvent.RecipientUserId,
notificationEvent.RecipientEmail,
notificationEvent.MetadataJson,
notificationEvent.CreatedAt,
notificationEvent.ReadAt))
.ToListAsync(ct);
await SendOkAsync(notifications, ct);
}
}

View File

@@ -0,0 +1,39 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Data;
namespace Socialize.Modules.Notifications.Handlers;
public class MarkNotificationAsReadHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/notifications/{id}/read");
Options(o => o.WithTags("Notifications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
NotificationEvent? notificationEvent = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (notificationEvent is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
notificationEvent.ReadAt = notificationEvent.ReadAt ?? DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendNoContentAsync(ct);
}
}

View File

@@ -0,0 +1,30 @@
using Socialize.Modules.Notifications.Contracts;
using Socialize.Modules.Notifications.Data;
namespace Socialize.Modules.Notifications.Services;
public class NotificationEventWriter(
AppDbContext dbContext)
: INotificationEventWriter
{
public async Task WriteAsync(NotificationEventWriteModel model, CancellationToken cancellationToken = default)
{
NotificationEvent notificationEvent = new()
{
Id = Guid.NewGuid(),
WorkspaceId = model.WorkspaceId,
ContentItemId = model.ContentItemId,
EventType = model.EventType,
EntityType = model.EntityType,
EntityId = model.EntityId,
Message = model.Message,
RecipientUserId = model.RecipientUserId,
RecipientEmail = model.RecipientEmail,
MetadataJson = model.MetadataJson,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.NotificationEvents.Add(notificationEvent);
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,15 @@
namespace Socialize.Modules.Projects.Data;
public class Project
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public string? Notes { get; set; }
public required string Status { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset EndDate { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Projects.Data;
namespace Socialize.Modules.Projects;
public static class DependencyInjection
{
public static WebApplicationBuilder AddProjectsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,115 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Projects.Handlers;
public record CreateProjectRequest(
Guid WorkspaceId,
Guid ClientId,
string Name,
DateTimeOffset StartDate,
DateTimeOffset EndDate,
string? Description,
string? Notes);
public class CreateProjectRequestValidator
: Validator<CreateProjectRequest>
{
public CreateProjectRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.StartDate).NotEmpty();
RuleFor(x => x.EndDate)
.NotEmpty()
.GreaterThanOrEqualTo(x => x.StartDate);
RuleFor(x => x.Description).MaximumLength(4000);
RuleFor(x => x.Notes).MaximumLength(4000);
}
}
public class CreateProjectHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateProjectRequest, ProjectDto>
{
public override void Configure()
{
Post("/api/projects");
Options(o => o.WithTags("Projects"));
}
public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool clientExists = await dbContext.Clients
.AnyAsync(
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
ct);
if (!clientExists)
{
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
bool duplicateProject = await dbContext.Projects
.AnyAsync(
project => project.ClientId == request.ClientId && project.Name == normalizedName,
ct);
if (duplicateProject)
{
AddError(request => request.Name, "A project with this name already exists for the selected client.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Project project = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId,
Name = normalizedName,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
Status = "Planned",
StartDate = request.StartDate,
EndDate = request.EndDate,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Projects.Add(project);
await dbContext.SaveChangesAsync(ct);
ProjectDto dto = new(
project.Id,
project.WorkspaceId,
project.ClientId,
project.Name,
project.Description,
project.Notes,
project.Status,
project.StartDate,
project.EndDate);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,86 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Projects.Data;
namespace Socialize.Modules.Projects.Handlers;
public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId);
public record ProjectDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
string Name,
string? Description,
string? Notes,
string Status,
DateTimeOffset StartDate,
DateTimeOffset EndDate);
public class GetProjectsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetProjectsRequest, IReadOnlyCollection<ProjectDto>>
{
public override void Configure()
{
Get("/api/projects");
Options(o => o.WithTags("Projects"));
}
public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct)
{
IQueryable<Project> query = dbContext.Projects.AsQueryable();
if (accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(project => clientScopeIds.Contains(project.ClientId));
}
if (projectScopeIds.Count > 0)
{
query = query.Where(project => projectScopeIds.Contains(project.Id));
}
}
if (request.ClientId.HasValue)
{
query = query.Where(project => project.ClientId == request.ClientId.Value);
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
}
List<ProjectDto> projects = await query
.OrderBy(project => project.Name)
.Select(project => new ProjectDto(
project.Id,
project.WorkspaceId,
project.ClientId,
project.Name,
project.Description,
project.Notes,
project.Status,
project.StartDate,
project.EndDate))
.ToListAsync(ct);
await SendOkAsync(projects, ct);
}
}

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

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

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

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