chore: moving towards agentic development
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs
Normal file
16
backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
backend/src/Socialize.Api/Modules/Clients/Data/Client.cs
Normal file
14
backend/src/Socialize.Api/Modules/Clients/Data/Client.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs
Normal file
16
backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public interface IUserLookup
|
||||
{
|
||||
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public record UserReference(
|
||||
Guid Id,
|
||||
string Fullname,
|
||||
string? PortraitUrl);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal file
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal 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) { }
|
||||
}
|
||||
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal file
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal 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}";
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal file
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal file
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class RoleModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal file
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15
backend/src/Socialize.Api/Modules/Projects/Data/Project.cs
Normal file
15
backend/src/Socialize.Api/Modules/Projects/Data/Project.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Modules.Workspaces.Handlers;
|
||||
|
||||
public record WorkspaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string TimeZone,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetWorkspacesHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/workspaces");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||
}
|
||||
|
||||
List<WorkspaceDto> workspaces = await query
|
||||
.OrderBy(workspace => workspace.Name)
|
||||
.Select(workspace => new WorkspaceDto(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(workspaces, ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user