feat: pivot to social media workflow app
This commit is contained in:
13
backend/Modules/Approvals/Data/ApprovalDecision.cs
Normal file
13
backend/Modules/Approvals/Data/ApprovalDecision.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Socialize.Modules.Approvals.Data;
|
||||
|
||||
public class ApprovalDecision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ApprovalRequestId { get; set; }
|
||||
public required string Decision { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public Guid? DecidedByUserId { get; set; }
|
||||
public required string DecidedByName { get; set; }
|
||||
public required string DecidedByEmail { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
17
backend/Modules/Approvals/Data/ApprovalRequest.cs
Normal file
17
backend/Modules/Approvals/Data/ApprovalRequest.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Socialize.Modules.Approvals.Data;
|
||||
|
||||
public class ApprovalRequest
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public required string Stage { get; set; }
|
||||
public required string ReviewerName { get; set; }
|
||||
public required string ReviewerEmail { get; set; }
|
||||
public Guid RequestedByUserId { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
public required string State { get; set; }
|
||||
public required string AccessToken { get; set; }
|
||||
public DateTimeOffset SentAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
12
backend/Modules/Approvals/DependencyInjection.cs
Normal file
12
backend/Modules/Approvals/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Approvals.Data;
|
||||
|
||||
namespace Socialize.Modules.Approvals;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddApprovalsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Security.Cryptography;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record CreateApprovalRequestRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
DateTimeOffset? DueAt);
|
||||
|
||||
public class CreateApprovalRequestRequestValidator
|
||||
: Validator<CreateApprovalRequestRequest>
|
||||
{
|
||||
public CreateApprovalRequestRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApprovalRequestHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ApprovalRequest approval = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
Stage = request.Stage.Trim(),
|
||||
ReviewerName = request.ReviewerName.Trim(),
|
||||
ReviewerEmail = request.ReviewerEmail.Trim(),
|
||||
RequestedByUserId = User.GetUserId(),
|
||||
DueAt = request.DueAt,
|
||||
State = "Pending",
|
||||
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ApprovalRequests.Add(approval);
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = "In internal review";
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = "In client review";
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.requested",
|
||||
"ApprovalRequest",
|
||||
approval.Id,
|
||||
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
|
||||
null,
|
||||
approval.ReviewerEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
|
||||
ct);
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
[]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record GetApprovalsRequest(Guid ContentItemId);
|
||||
|
||||
public record ApprovalDecisionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalRequestId,
|
||||
string Decision,
|
||||
string? Comment,
|
||||
Guid? DecidedByUserId,
|
||||
string DecidedByName,
|
||||
string DecidedByEmail,
|
||||
string? DecidedByPortraitUrl,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record ApprovalRequestDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
Guid RequestedByUserId,
|
||||
DateTimeOffset? DueAt,
|
||||
string State,
|
||||
string AccessToken,
|
||||
DateTimeOffset SentAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
IReadOnlyCollection<ApprovalDecisionDto> Decisions);
|
||||
|
||||
public class GetApprovalsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||
.OrderByDescending(approval => approval.SentAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> approvalIds = approvals
|
||||
.Select(approval => approval.Id)
|
||||
.ToList();
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(decision => approvalIds.Contains(decision.ApprovalRequestId))
|
||||
.OrderByDescending(decision => decision.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(decision => decision.DecidedByUserId.HasValue)
|
||||
.Select(decision => decision.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalRequestDto> dtos = approvals
|
||||
.Select(approval => new ApprovalRequestDto(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisions
|
||||
.Where(decision => decision.ApprovalRequestId == approval.Id)
|
||||
.Select(decision => new ApprovalDecisionDto(
|
||||
decision.Id,
|
||||
decision.ApprovalRequestId,
|
||||
decision.Decision,
|
||||
decision.Comment,
|
||||
decision.DecidedByUserId,
|
||||
decision.DecidedByName,
|
||||
decision.DecidedByEmail,
|
||||
decision.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value)
|
||||
: null,
|
||||
decision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(dtos, ct);
|
||||
}
|
||||
}
|
||||
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record SubmitApprovalDecisionRequest(
|
||||
string Decision,
|
||||
string? Comment,
|
||||
string? ReviewerName,
|
||||
string? ReviewerEmail);
|
||||
|
||||
public class SubmitApprovalDecisionRequestValidator
|
||||
: Validator<SubmitApprovalDecisionRequest>
|
||||
{
|
||||
public SubmitApprovalDecisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Comment).MaximumLength(2048);
|
||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitApprovalDecisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals/{id}/decisions");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (approval is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
|
||||
if (contentItem is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (User?.Identity?.IsAuthenticated == true &&
|
||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedDecision = request.Decision.Trim();
|
||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetAlias() ?? User.GetName()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
|
||||
string decidedByEmail = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetEmail()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
|
||||
|
||||
ApprovalDecision decision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalRequestId = approval.Id,
|
||||
Decision = normalizedDecision,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||
DecidedByName = decidedByName,
|
||||
DecidedByEmail = decidedByEmail,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
approval.State = normalizedDecision;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Ready for client review",
|
||||
"Changes requested" => "Changes requested internally",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Approved",
|
||||
"Changes requested" => "Changes requested by client",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
}
|
||||
|
||||
dbContext.ApprovalDecisions.Add(decision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.decision.recorded",
|
||||
"ApprovalDecision",
|
||||
decision.Id,
|
||||
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
||||
null,
|
||||
decidedByEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||
.OrderByDescending(candidate => candidate.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(candidate => candidate.DecidedByUserId.HasValue)
|
||||
.Select(candidate => candidate.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalDecisionDto> decisionDtos = decisions
|
||||
.Select(candidate => new ApprovalDecisionDto(
|
||||
candidate.Id,
|
||||
candidate.ApprovalRequestId,
|
||||
candidate.Decision,
|
||||
candidate.Comment,
|
||||
candidate.DecidedByUserId,
|
||||
candidate.DecidedByName,
|
||||
candidate.DecidedByEmail,
|
||||
candidate.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
|
||||
: null,
|
||||
candidate.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisionDtos);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
16
backend/Modules/Assets/Data/Asset.cs
Normal file
16
backend/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; }
|
||||
}
|
||||
13
backend/Modules/Assets/Data/AssetRevision.cs
Normal file
13
backend/Modules/Assets/Data/AssetRevision.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Socialize.Modules.Assets.Data;
|
||||
|
||||
public class AssetRevision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid AssetId { get; set; }
|
||||
public int RevisionNumber { get; set; }
|
||||
public required string SourceReference { get; set; }
|
||||
public string? PreviewUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/Assets/DependencyInjection.cs
Normal file
12
backend/Modules/Assets/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Assets.Data;
|
||||
|
||||
namespace Socialize.Modules.Assets;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddAssetsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
102
backend/Modules/Assets/Handlers/CreateAssetRevision.cs
Normal file
102
backend/Modules/Assets/Handlers/CreateAssetRevision.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record CreateAssetRevisionRequest(
|
||||
string SourceReference,
|
||||
string? PreviewUrl,
|
||||
string? Notes);
|
||||
|
||||
public class CreateAssetRevisionRequestValidator
|
||||
: Validator<CreateAssetRevisionRequest>
|
||||
{
|
||||
public CreateAssetRevisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.SourceReference).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.Notes).MaximumLength(1024);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateAssetRevisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/assets/{id}/revisions");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateAssetRevisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (asset is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
||||
|
||||
if (contentItem is not null &&
|
||||
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
int revisionNumber = asset.CurrentRevisionNumber + 1;
|
||||
asset.CurrentRevisionNumber = revisionNumber;
|
||||
asset.PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? asset.PreviewUrl : request.PreviewUrl.Trim();
|
||||
|
||||
AssetRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = asset.Id,
|
||||
RevisionNumber = revisionNumber,
|
||||
SourceReference = request.SourceReference.Trim(),
|
||||
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||
CreatedByUserId = User.GetUserId(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
if (contentItem is not null)
|
||||
{
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
"asset.revision.created",
|
||||
"AssetRevision",
|
||||
revision.Id,
|
||||
$"A new asset revision was added to {asset.DisplayName}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
$$"""{"revisionNumber":"{{revisionNumber}}"}"""),
|
||||
ct);
|
||||
}
|
||||
|
||||
AssetRevisionDto dto = new(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
130
backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs
Normal file
130
backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record CreateGoogleDriveAssetRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
string DisplayName,
|
||||
string GoogleDriveFileId,
|
||||
string GoogleDriveLink,
|
||||
string? PreviewUrl);
|
||||
|
||||
public class CreateGoogleDriveAssetRequestValidator
|
||||
: Validator<CreateGoogleDriveAssetRequest>
|
||||
{
|
||||
public CreateGoogleDriveAssetRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.GoogleDriveFileId).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.GoogleDriveLink).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateGoogleDriveAssetHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/assets/google-drive");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateGoogleDriveAssetRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset asset = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
AssetType = request.AssetType.Trim(),
|
||||
SourceType = "GoogleDrive",
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
|
||||
GoogleDriveLink = request.GoogleDriveLink.Trim(),
|
||||
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
AssetRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = asset.Id,
|
||||
RevisionNumber = 1,
|
||||
SourceReference = asset.GoogleDriveLink,
|
||||
PreviewUrl = asset.PreviewUrl,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Assets.Add(asset);
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
"asset.google-drive-linked",
|
||||
"Asset",
|
||||
asset.Id,
|
||||
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"googleDriveFileId":"{{asset.GoogleDriveFileId}}"}"""),
|
||||
ct);
|
||||
|
||||
AssetDto dto = new(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
[
|
||||
new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt)
|
||||
]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
89
backend/Modules/Assets/Handlers/GetAssets.cs
Normal file
89
backend/Modules/Assets/Handlers/GetAssets.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record GetAssetsRequest(Guid ContentItemId);
|
||||
|
||||
public record AssetRevisionDto(
|
||||
Guid Id,
|
||||
Guid AssetId,
|
||||
int RevisionNumber,
|
||||
string SourceReference,
|
||||
string? PreviewUrl,
|
||||
string? Notes,
|
||||
Guid? CreatedByUserId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record AssetDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
string SourceType,
|
||||
string DisplayName,
|
||||
string? GoogleDriveFileId,
|
||||
string? GoogleDriveLink,
|
||||
string? PreviewUrl,
|
||||
int CurrentRevisionNumber,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyCollection<AssetRevisionDto> Revisions);
|
||||
|
||||
public class GetAssetsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/assets");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await dbContext.Assets
|
||||
.Where(asset => asset.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(asset => asset.DisplayName)
|
||||
.Select(asset => new AssetDto(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
dbContext.AssetRevisions
|
||||
.Where(revision => revision.AssetId == asset.Id)
|
||||
.OrderByDescending(revision => revision.RevisionNumber)
|
||||
.Select(revision => new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(assets, ct);
|
||||
}
|
||||
}
|
||||
14
backend/Modules/Clients/Data/Client.cs
Normal file
14
backend/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; }
|
||||
}
|
||||
12
backend/Modules/Clients/DependencyInjection.cs
Normal file
12
backend/Modules/Clients/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddClientsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
65
backend/Modules/Clients/Handlers/ChangeClientPortrait.cs
Normal file
65
backend/Modules/Clients/Handlers/ChangeClientPortrait.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record ChangeClientPortraitRequest(
|
||||
IFormFile File);
|
||||
|
||||
public record ChangeClientPortraitResponse(
|
||||
string BlobUrl);
|
||||
|
||||
public sealed class ChangeClientPortraitRequestValidator : Validator<ChangeClientPortraitRequest>
|
||||
{
|
||||
public ChangeClientPortraitRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeClientPortraitHandler(
|
||||
AppDbContext clientsDbContext,
|
||||
IBlobStorage blobStorage,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ChangeClientPortraitRequest, ChangeClientPortraitResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/clients/{id}/portrait");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeClientPortraitRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (client is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Clients,
|
||||
$"{client.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
client.PortraitUrl = blobUrl;
|
||||
await clientsDbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(new ChangeClientPortraitResponse(blobUrl), ct);
|
||||
}
|
||||
}
|
||||
101
backend/Modules/Clients/Handlers/CreateClient.cs
Normal file
101
backend/Modules/Clients/Handlers/CreateClient.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record CreateClientRequest(
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class CreateClientRequestValidator
|
||||
: Validator<CreateClientRequest>
|
||||
{
|
||||
public CreateClientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
|
||||
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
|
||||
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateClientHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<CreateClientRequest, ClientDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/clients");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool workspaceExists = await dbContext.Workspaces
|
||||
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
|
||||
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
|
||||
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
|
||||
|
||||
bool duplicateClient = await dbContext.Clients
|
||||
.AnyAsync(
|
||||
client => client.WorkspaceId == request.WorkspaceId && client.Name == normalizedName,
|
||||
ct);
|
||||
|
||||
if (duplicateClient)
|
||||
{
|
||||
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Client client = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
Name = normalizedName,
|
||||
Status = "Active",
|
||||
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
|
||||
PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName,
|
||||
PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail,
|
||||
PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Clients.Add(client);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
ClientDto dto = new(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
73
backend/Modules/Clients/Handlers/GetClients.cs
Normal file
73
backend/Modules/Clients/Handlers/GetClients.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record GetClientsRequest(Guid? WorkspaceId);
|
||||
|
||||
public record ClientDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string Status,
|
||||
string? PortraitUrl,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class GetClientsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetClientsRequest, IReadOnlyCollection<ClientDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/clients");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetClientsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
||||
|
||||
if (accessScopeService.IsManager(User))
|
||||
{
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
|
||||
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
List<ClientDto> clients = await query
|
||||
.OrderBy(client => client.Name)
|
||||
.Select(client => new ClientDto(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(clients, ct);
|
||||
}
|
||||
}
|
||||
98
backend/Modules/Clients/Handlers/UpdateClient.cs
Normal file
98
backend/Modules/Clients/Handlers/UpdateClient.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record UpdateClientRequest(
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
string Status,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class UpdateClientRequestValidator
|
||||
: Validator<UpdateClientRequest>
|
||||
{
|
||||
public UpdateClientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
|
||||
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
|
||||
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateClientHandler(
|
||||
AppDbContext clientsDbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<UpdateClientRequest, ClientDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/clients/{id}");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (client is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
string normalizedStatus = request.Status.Trim();
|
||||
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
|
||||
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
|
||||
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
|
||||
|
||||
bool duplicateClient = await clientsDbContext.Clients
|
||||
.AnyAsync(
|
||||
candidate => candidate.Id != id
|
||||
&& candidate.WorkspaceId == client.WorkspaceId
|
||||
&& candidate.Name == normalizedName,
|
||||
ct);
|
||||
|
||||
if (duplicateClient)
|
||||
{
|
||||
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
client.Name = normalizedName;
|
||||
client.Status = normalizedStatus;
|
||||
client.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
|
||||
client.PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName;
|
||||
client.PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail;
|
||||
client.PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl;
|
||||
|
||||
await clientsDbContext.SaveChangesAsync(ct);
|
||||
|
||||
ClientDto dto = new(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
16
backend/Modules/Comments/Data/Comment.cs
Normal file
16
backend/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; }
|
||||
}
|
||||
12
backend/Modules/Comments/DependencyInjection.cs
Normal file
12
backend/Modules/Comments/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Comments.Data;
|
||||
|
||||
namespace Socialize.Modules.Comments;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCommentsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
120
backend/Modules/Comments/Handlers/CreateComment.cs
Normal file
120
backend/Modules/Comments/Handlers/CreateComment.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public record CreateCommentRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? ParentCommentId,
|
||||
string Body);
|
||||
|
||||
public class CreateCommentRequestValidator
|
||||
: Validator<CreateCommentRequest>
|
||||
{
|
||||
public CreateCommentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateCommentRequest, CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/comments");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ParentCommentId.HasValue)
|
||||
{
|
||||
bool parentExists = await dbContext.Comments
|
||||
.AnyAsync(
|
||||
comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId,
|
||||
ct);
|
||||
|
||||
if (!parentExists)
|
||||
{
|
||||
AddError(request => request.ParentCommentId, "The selected parent comment does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Comment comment = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
ParentCommentId = request.ParentCommentId,
|
||||
AuthorUserId = User.GetUserId(),
|
||||
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
AuthorEmail = User.GetEmail(),
|
||||
Body = request.Body.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Comments.Add(comment);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
string? authorPortraitUrl = await dbContext.Users
|
||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||
.Select(candidate => candidate.PortraitUrl)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
"comment.created",
|
||||
"Comment",
|
||||
comment.Id,
|
||||
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""),
|
||||
ct);
|
||||
|
||||
CommentDto dto = new(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraitUrl,
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
80
backend/Modules/Comments/Handlers/GetComments.cs
Normal file
80
backend/Modules/Comments/Handlers/GetComments.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public record GetCommentsRequest(Guid ContentItemId);
|
||||
|
||||
public record CommentDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? ParentCommentId,
|
||||
Guid AuthorUserId,
|
||||
string AuthorDisplayName,
|
||||
string AuthorEmail,
|
||||
string? AuthorPortraitUrl,
|
||||
string Body,
|
||||
bool IsResolved,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
public class GetCommentsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetCommentsRequest, IReadOnlyCollection<CommentDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/comments");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetCommentsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Comment> comments = await dbContext.Comments
|
||||
.Where(comment => comment.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> authorIds = comments
|
||||
.Select(comment => comment.AuthorUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> authorPortraits = await dbContext.Users
|
||||
.Where(user => authorIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<CommentDto> dtos = comments
|
||||
.Select(comment => new CommentDto(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraits.GetValueOrDefault(comment.AuthorUserId),
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(dtos, ct);
|
||||
}
|
||||
}
|
||||
84
backend/Modules/Comments/Handlers/ResolveComment.cs
Normal file
84
backend/Modules/Comments/Handlers/ResolveComment.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public class ResolveCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: EndpointWithoutRequest<CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/comments/{id}/resolve");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (comment is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
|
||||
if (contentItem is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|
||||
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
|
||||
|
||||
if (!canResolve)
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
comment.IsResolved = true;
|
||||
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
string? authorPortraitUrl = await dbContext.Users
|
||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||
.Select(candidate => candidate.PortraitUrl)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
"comment.resolved",
|
||||
"Comment",
|
||||
comment.Id,
|
||||
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
ct);
|
||||
|
||||
CommentDto dto = new(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraitUrl,
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
18
backend/Modules/ContentItems/Data/ContentItem.cs
Normal file
18
backend/Modules/ContentItems/Data/ContentItem.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Socialize.Modules.ContentItems.Data;
|
||||
|
||||
public class ContentItem
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ClientId { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string PublicationMessage { get; set; }
|
||||
public required string PublicationTargets { get; set; }
|
||||
public string? Hashtags { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public DateTimeOffset? DueDate { get; set; }
|
||||
public required string CurrentRevisionLabel { get; set; }
|
||||
public int CurrentRevisionNumber { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
16
backend/Modules/ContentItems/Data/ContentItemRevision.cs
Normal file
16
backend/Modules/ContentItems/Data/ContentItemRevision.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Modules.ContentItems.Data;
|
||||
|
||||
public class ContentItemRevision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public int RevisionNumber { get; set; }
|
||||
public required string RevisionLabel { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string PublicationMessage { get; set; }
|
||||
public required string PublicationTargets { get; set; }
|
||||
public string? Hashtags { get; set; }
|
||||
public string? ChangeSummary { get; set; }
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/ContentItems/DependencyInjection.cs
Normal file
12
backend/Modules/ContentItems/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddContentItemsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
148
backend/Modules/ContentItems/Handlers/CreateContentItem.cs
Normal file
148
backend/Modules/ContentItems/Handlers/CreateContentItem.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record CreateContentItemRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
DateTimeOffset? DueDate);
|
||||
|
||||
public class CreateContentItemRequestValidator
|
||||
: Validator<CreateContentItemRequest>
|
||||
{
|
||||
public CreateContentItemRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ClientId).NotEmpty();
|
||||
RuleFor(x => x.ProjectId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
|
||||
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
|
||||
RuleFor(x => x.Hashtags).MaximumLength(1024);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateContentItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateContentItemRequest, ContentItemDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/content-items");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool workspaceExists = await dbContext.Workspaces
|
||||
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool clientExists = await dbContext.Clients
|
||||
.AnyAsync(
|
||||
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (!clientExists)
|
||||
{
|
||||
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool projectExists = await dbContext.Projects
|
||||
.AnyAsync(
|
||||
project => project.Id == request.ProjectId &&
|
||||
project.WorkspaceId == request.WorkspaceId &&
|
||||
project.ClientId == request.ClientId,
|
||||
ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem item = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ClientId = request.ClientId,
|
||||
ProjectId = request.ProjectId,
|
||||
Title = request.Title.Trim(),
|
||||
PublicationMessage = request.PublicationMessage.Trim(),
|
||||
PublicationTargets = request.PublicationTargets.Trim(),
|
||||
Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(),
|
||||
Status = "Draft",
|
||||
DueDate = request.DueDate,
|
||||
CurrentRevisionLabel = "v1",
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ContentItems.Add(item);
|
||||
dbContext.ContentItemRevisions.Add(new ContentItemRevision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContentItemId = item.Id,
|
||||
RevisionNumber = 1,
|
||||
RevisionLabel = "v1",
|
||||
Title = item.Title,
|
||||
PublicationMessage = item.PublicationMessage,
|
||||
PublicationTargets = item.PublicationTargets,
|
||||
Hashtags = item.Hashtags,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
item.WorkspaceId,
|
||||
item.Id,
|
||||
"content-item.created",
|
||||
"ContentItem",
|
||||
item.Id,
|
||||
$"Content item {item.Title} was created.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""),
|
||||
ct);
|
||||
|
||||
ContentItemDto dto = new(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
68
backend/Modules/ContentItems/Handlers/GetContentItem.cs
Normal file
68
backend/Modules/ContentItems/Handlers/GetContentItem.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record ContentItemDetailDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string Status,
|
||||
DateTimeOffset? DueDate,
|
||||
string CurrentRevisionLabel,
|
||||
int CurrentRevisionNumber,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetContentItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<ContentItemDetailDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/content-items/{id}");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItemDetailDto? item = await dbContext.ContentItems
|
||||
.Where(candidate => candidate.Id == id)
|
||||
.Select(candidate => new ContentItemDetailDto(
|
||||
candidate.Id,
|
||||
candidate.WorkspaceId,
|
||||
candidate.ClientId,
|
||||
candidate.ProjectId,
|
||||
candidate.Title,
|
||||
candidate.PublicationMessage,
|
||||
candidate.PublicationTargets,
|
||||
candidate.Hashtags,
|
||||
candidate.Status,
|
||||
candidate.DueDate,
|
||||
candidate.CurrentRevisionLabel,
|
||||
candidate.CurrentRevisionNumber,
|
||||
candidate.CreatedAt))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
91
backend/Modules/ContentItems/Handlers/GetContentItems.cs
Normal file
91
backend/Modules/ContentItems/Handlers/GetContentItems.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
|
||||
|
||||
public record ContentItemDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string Status,
|
||||
DateTimeOffset? DueDate,
|
||||
string CurrentRevisionLabel,
|
||||
int CurrentRevisionNumber);
|
||||
|
||||
public class GetContentItemsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetContentItemsRequest, IReadOnlyCollection<ContentItemDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/content-items");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
|
||||
|
||||
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
||||
}
|
||||
|
||||
if (projectScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
if (request.ProjectId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ClientId == request.ClientId.Value);
|
||||
}
|
||||
|
||||
List<ContentItemDto> items = await query
|
||||
.OrderBy(item => item.DueDate)
|
||||
.ThenBy(item => item.Title)
|
||||
.Select(item => new ContentItemDto(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(items, ct);
|
||||
}
|
||||
}
|
||||
105
backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs
Normal file
105
backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record UpdateContentItemStatusRequest(string Status);
|
||||
|
||||
public class UpdateContentItemStatusRequestValidator
|
||||
: Validator<UpdateContentItemStatusRequest>
|
||||
{
|
||||
public UpdateContentItemStatusRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateContentItemStatusHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
||||
{
|
||||
private static readonly HashSet<string> AllowedStatuses =
|
||||
[
|
||||
"Draft",
|
||||
"In internal review",
|
||||
"Changes requested internally",
|
||||
"Internal changes in progress",
|
||||
"Ready for client review",
|
||||
"In client review",
|
||||
"Changes requested by client",
|
||||
"Client changes in progress",
|
||||
"Approved",
|
||||
"Rejected",
|
||||
"Ready to publish",
|
||||
"Published",
|
||||
"Archived",
|
||||
];
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/content-items/{id}/status");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedStatus = request.Status.Trim();
|
||||
if (!AllowedStatuses.Contains(normalizedStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The requested status is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
item.Status = normalizedStatus;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
item.WorkspaceId,
|
||||
item.Id,
|
||||
"content-item.status.updated",
|
||||
"ContentItem",
|
||||
item.Id,
|
||||
$"Status changed to {item.Status} for {item.Title}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
$$"""{"status":"{{item.Status}}"}"""),
|
||||
ct);
|
||||
|
||||
ContentItemDetailDto dto = new(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber,
|
||||
item.CreatedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class Album : Entity
|
||||
{
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
[MaxLength(255)] public required string Title { get; set; }
|
||||
public IList<AlbumPhoto> Photos { get; set; } = new List<AlbumPhoto>();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class AlbumPhoto : Entity
|
||||
{
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
public Guid AlbumId { get; set; }
|
||||
public Album Album { get; init; } = null!;
|
||||
[MaxLength(2048)] public required string OriginalUrl { get; set; }
|
||||
[MaxLength(2048)] public required string ThumbnailUrl { get; set; }
|
||||
[MaxLength(256)] public string? Caption { get; set; }
|
||||
public int Order { get; set; }
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class ContentsDbContext(
|
||||
DbContextOptions<ContentsDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Content";
|
||||
|
||||
public DbSet<Album> Albums => Set<Album>();
|
||||
public DbSet<AlbumPhoto> AlbumPhotos => Set<AlbumPhoto>();
|
||||
|
||||
protected override void OnModelCreating(
|
||||
ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
// Album configuration
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.HasQueryFilter(a => !a.IsDeleted);
|
||||
|
||||
// AlbumPhoto configuration
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.HasOne(ap => ap.Album)
|
||||
.WithMany(a => a.Photos)
|
||||
.HasForeignKey(ap => ap.AlbumId)
|
||||
.IsRequired();
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.HasQueryFilter(ap => !ap.IsDeleted);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddContentModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddDbContext<ContentsDbContext>(configureAction);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseContentModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using ContentsDbContext context = scope.ServiceProvider.GetRequiredService<ContentsDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId,
|
||||
IFormFile File,
|
||||
string? Caption = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumResponse(
|
||||
Guid PhotoId,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumRequest>
|
||||
{
|
||||
private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
private static readonly string[] AllowedImageTypes =
|
||||
[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
];
|
||||
|
||||
public AddPhotoToAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.Must(file => AllowedImageTypes.Contains(file.ContentType))
|
||||
.WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)")
|
||||
.Must(file => file.Length <= MaxFileSizeBytes)
|
||||
.WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB");
|
||||
|
||||
RuleFor(x => x.Caption)
|
||||
.MaximumLength(255);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class AddPhotoToAlbumHandler(
|
||||
ContentsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
|
||||
{
|
||||
private const int MaxThumbnailWidth = 500;
|
||||
private const int MaxThumbnailHeight = 500;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums/{AlbumId}/photos");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
// Fetch the album we want to add photos to
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a photo with the same ID already exists
|
||||
bool existingPhoto = await context
|
||||
.AlbumPhotos
|
||||
.AnyAsync(p => p.Id == request.PhotoId, ct);
|
||||
|
||||
if (existingPhoto)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
(string originalUrl, string thumbnailUrl) = await ProcessAndUploadImage(request, ct);
|
||||
|
||||
// Get the next order number
|
||||
int nextOrder = await context
|
||||
.AlbumPhotos
|
||||
.Where(p => p.AlbumId == request.AlbumId)
|
||||
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
|
||||
|
||||
// Create the album photo
|
||||
AlbumPhoto photo = new()
|
||||
{
|
||||
Id = request.PhotoId,
|
||||
CreatedBy = userId,
|
||||
AlbumId = request.AlbumId,
|
||||
OriginalUrl = originalUrl,
|
||||
ThumbnailUrl = thumbnailUrl,
|
||||
Caption = request.Caption,
|
||||
Order = nextOrder + 1
|
||||
};
|
||||
|
||||
context.AlbumPhotos.Add(photo);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl),
|
||||
ct);
|
||||
}
|
||||
catch (UnknownImageFormatException)
|
||||
{
|
||||
await SendStringAsync("Invalid image format", 400, cancellation: ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await SendStringAsync("Error processing image", 500, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string originalFileName = Path.GetFileName(request.File.FileName);
|
||||
string nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
|
||||
string extension = Path.GetExtension(originalFileName);
|
||||
|
||||
string filenameOriginal = $"{nameWithoutExt}{extension}";
|
||||
string filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}";
|
||||
|
||||
string blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}";
|
||||
string blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}";
|
||||
|
||||
// Process the original image
|
||||
await using Stream originalStream = request.File.OpenReadStream();
|
||||
using Image image = await Image.LoadAsync(originalStream, ct);
|
||||
|
||||
// Calculate target size while preserving the original aspect ratio
|
||||
int originalWidth = image.Width;
|
||||
int originalHeight = image.Height;
|
||||
|
||||
double ratioX = (double)MaxThumbnailWidth / originalWidth;
|
||||
double ratioY = (double)MaxThumbnailHeight / originalHeight;
|
||||
double ratio = Math.Min(ratioX, ratioY);
|
||||
|
||||
int newWidth = (int)(originalWidth * ratio);
|
||||
int newHeight = (int)(originalHeight * ratio);
|
||||
|
||||
// Create thumbnail
|
||||
using MemoryStream thumbnailStream = new();
|
||||
image.Mutate(x => x.Resize(newWidth, newHeight));
|
||||
await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct);
|
||||
thumbnailStream.Position = 0;
|
||||
|
||||
// Upload both versions
|
||||
string originalUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobOriginal,
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
string thumbnailUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobThumbnail,
|
||||
thumbnailStream,
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
return (originalUrl, thumbnailUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumRequest(
|
||||
Guid AlbumId,
|
||||
string Title,
|
||||
string? Description = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumResponse(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateAlbumRequestValidator : Validator<CreateAlbumRequest>
|
||||
{
|
||||
public CreateAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.MaximumLength(255);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<CreateAlbumRequest, CreateAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if an album with the same ID already exists
|
||||
bool existingAlbum = await context
|
||||
.Albums
|
||||
.AnyAsync(a => a.Id == request.AlbumId, ct);
|
||||
|
||||
if (existingAlbum)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Album album = new() { Id = request.AlbumId, CreatedBy = User.GetUserId(), Title = request.Title };
|
||||
|
||||
context.Albums.Add(album);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new CreateAlbumResponse(album.Id),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public record AlbumPhotoDto(
|
||||
Guid Id,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl,
|
||||
string? Caption,
|
||||
int Order,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
IReadOnlyList<AlbumPhotoDto> Photos,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetAlbumRequestValidator : Validator<GetAlbumRequest>
|
||||
{
|
||||
public GetAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<GetAlbumRequest, GetAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos.OrderBy(p => p.Order))
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AlbumPhotoDto> photos = album.Photos
|
||||
.Select(p => new AlbumPhotoDto(
|
||||
p.Id,
|
||||
p.OriginalUrl,
|
||||
p.ThumbnailUrl,
|
||||
p.Caption,
|
||||
p.Order,
|
||||
p.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(
|
||||
new GetAlbumResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
photos,
|
||||
album.CreatedAt),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemoveAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemoveAlbumRequestValidator : Validator<RemoveAlbumRequest>
|
||||
{
|
||||
public RemoveAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemoveAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemoveAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemoveAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the album
|
||||
album.DeletedBy = userId;
|
||||
album.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Soft delete all photos in the album
|
||||
foreach (AlbumPhoto photo in album.Photos)
|
||||
{
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemovePhotoFromAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemovePhotoFromAlbumRequestValidator : Validator<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public RemovePhotoFromAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemovePhotoFromAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}/photos/{PhotoId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemovePhotoFromAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
AlbumPhoto? photo = album.Photos
|
||||
.SingleOrDefault(p => p.Id == request.PhotoId);
|
||||
|
||||
if (photo is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the photo
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
[DbContext(typeof(ContentsDbContext))]
|
||||
[Migration("20250609212411_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Content")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Albums", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AlbumId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ThumbnailUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AlbumId");
|
||||
|
||||
b.ToTable("AlbumPhotos", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
|
||||
.WithMany("Photos")
|
||||
.HasForeignKey("AlbumId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Album");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Navigation("Photos");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Content");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Albums",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Albums", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AlbumPhotos",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
AlbumId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OriginalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
ThumbnailUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
Caption = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AlbumPhotos", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AlbumPhotos_Albums_AlbumId",
|
||||
column: x => x.AlbumId,
|
||||
principalSchema: "Content",
|
||||
principalTable: "Albums",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AlbumPhotos_AlbumId",
|
||||
schema: "Content",
|
||||
table: "AlbumPhotos",
|
||||
column: "AlbumId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AlbumPhotos",
|
||||
schema: "Content");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Albums",
|
||||
schema: "Content");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
[DbContext(typeof(ContentsDbContext))]
|
||||
partial class ContentsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Content")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Albums", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AlbumId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ThumbnailUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AlbumId");
|
||||
|
||||
b.ToTable("AlbumPhotos", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
|
||||
.WithMany("Photos")
|
||||
.HasForeignKey("AlbumId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Album");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Navigation("Photos");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Models;
|
||||
|
||||
[PublicAPI]
|
||||
public class ContentModel
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid CreatedBy { get; init; }
|
||||
public required string CreatedByName { get; init; }
|
||||
public required string? CreatedByPortraitUrl { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; init; }
|
||||
public DateTimeOffset? DeletedAt { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string HtmlFileUrl { get; init; } = "";
|
||||
public required string[]? Urls { get; init; }
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Models;
|
||||
|
||||
[PublicAPI]
|
||||
public record FollowModel(
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string? CreatorPortraitUrl);
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Configuration;
|
||||
|
||||
public class CreatorOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Creators";
|
||||
|
||||
public TimeSpan SlugReservationDuration { get; set; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Contracts;
|
||||
|
||||
public record CreatorReference(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
bool OnboardingComplete,
|
||||
bool AcceptCharges,
|
||||
string? StripeAccountId);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Contracts;
|
||||
|
||||
public interface ICreatorLookup
|
||||
{
|
||||
Task<CreatorReference?> GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Creator
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Soft‑delete flag (false by default, true once DeletedAt is set)
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
|
||||
[MaxLength(2048)] public string? BannerUrl { get; set; }
|
||||
[MaxLength(2048)] public string? PortraitUrl { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
[MaxLength(256)] public required string Name { get; set; }
|
||||
[MaxLength(128)] public required string Slug { get; set; }
|
||||
[MaxLength(256)] public string? Title { get; set; }
|
||||
|
||||
[MaxLength(21)] public string? StripeAccountId { get; set; }
|
||||
public bool IsStripeDetailsSubmitted { get; set; }
|
||||
public bool IsStripePayoutReady { get; set; }
|
||||
public bool IsStripeChargesEnabled { get; set; }
|
||||
public Socials Socials { get; set; } = new();
|
||||
public Presentation Presentation { get; set; } = new() { Description = "Welcome to my profile!" };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class CreatorsDbContext(
|
||||
DbContextOptions<CreatorsDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Creators";
|
||||
|
||||
public DbSet<Creator> Creators => Set<Creator>();
|
||||
public DbSet<Slugs> Slugs => Set<Slugs>();
|
||||
|
||||
protected override void OnModelCreating(
|
||||
ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Slugs>()
|
||||
.Property(x => x.NormalizedName)
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Slugs>()
|
||||
.HasIndex(x => x.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); // bool
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.OwnsOne<Socials>(x => x.Socials)
|
||||
.ToTable(nameof(Socials));
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.OwnsOne<Presentation>(x => x.Presentation)
|
||||
.ToTable(nameof(Presentation));
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.HasQueryFilter(c => !c.IsDeleted);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Presentation
|
||||
{
|
||||
public string Description { get; set; } = null!;
|
||||
[MaxLength(2048)] public string? VideoUrl { get; set; }
|
||||
[MaxLength(256)] public string? PhoneNumber { get; set; }
|
||||
[MaxLength(256)] public string? Email { get; set; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Slugs
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? UsedBy { get; set; }
|
||||
[MaxLength(128)] public string Name { get; set; } = null!;
|
||||
[MaxLength(128)] public string NormalizedName { get; set; } = null!;
|
||||
public DateTimeOffset ReservedUntil { get; set; }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Socials
|
||||
{
|
||||
[MaxLength(2048)] public string? FacebookUrl { get; set; }
|
||||
[MaxLength(2048)] public string? InstagramUrl { get; set; }
|
||||
[MaxLength(2048)] public string? XUrl { get; set; }
|
||||
[MaxLength(2048)] public string? LinkedInUrl { get; set; }
|
||||
[MaxLength(2048)] public string? TikTokUrl { get; set; }
|
||||
[MaxLength(2048)] public string? YoutubeUrl { get; set; }
|
||||
[MaxLength(2048)] public string? RedditUrl { get; set; }
|
||||
[MaxLength(2048)] public string? WebsiteUrl { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Hutopy.Modules.Creators.Services;
|
||||
|
||||
namespace Hutopy.Modules.Creators;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCreatorModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.Configure<CreatorOptions>(
|
||||
builder.Configuration.GetSection(CreatorOptions.ConfigurationSection));
|
||||
builder.Services.AddScoped<SlugPurger>();
|
||||
|
||||
builder.Services.AddDbContext<CreatorsDbContext>(configureAction);
|
||||
builder.Services.AddTransient<ICreatorLookup, CreatorLookup>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseCreatorModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using CreatorsDbContext context = scope.ServiceProvider.GetRequiredService<CreatorsDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public static class ChangeBanner
|
||||
{
|
||||
public record Request(
|
||||
Guid CreatorId,
|
||||
IFormFile File);
|
||||
|
||||
public record Response(
|
||||
string BlobUrl);
|
||||
|
||||
public class Handler(
|
||||
CreatorsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/banner");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
Request request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
creator.BannerUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new Response(blobUrl),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeEmailRequest(
|
||||
Guid CreatorId,
|
||||
string? Email);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangeEmailRequestValidator : Validator<ChangeEmailRequest>
|
||||
{
|
||||
public ChangeEmailRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.Must(email => email == null || !string.IsNullOrWhiteSpace(email))
|
||||
.WithMessage("Email cannot be empty if provided");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeEmailHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/email");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeEmailRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current user is the creator
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.Presentation.Email = request.Email?.Trim();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeLogoRequest(
|
||||
Guid CreatorId,
|
||||
IFormFile File);
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeLogoResponse(
|
||||
string BlobUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
|
||||
{
|
||||
public ChangeLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeLogoHandler(
|
||||
CreatorsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<ChangeLogoRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/logo");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeLogoRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
creator.PortraitUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new ChangeLogoResponse(blobUrl),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeNameRequest(
|
||||
Guid CreatorId,
|
||||
string Name);
|
||||
|
||||
[PublicAPI]
|
||||
internal sealed class ChangeNameRequestValidator
|
||||
: Validator<ChangeNameRequest>
|
||||
{
|
||||
public ChangeNameRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Name)
|
||||
.NotNull().WithMessage("You should specify the Name")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeNameHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeNameRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/name");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeNameRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Name = request.Name;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePhoneNumberRequest(
|
||||
Guid CreatorId,
|
||||
string? PhoneNumber);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangePhoneNumberRequestValidator : Validator<ChangePhoneNumberRequest>
|
||||
{
|
||||
public ChangePhoneNumberRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.PhoneNumber)
|
||||
.Must(phone => phone == null || !string.IsNullOrWhiteSpace(phone))
|
||||
.WithMessage("Phone number cannot be empty if provided");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePhoneNumberHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangePhoneNumberRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/phone");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePhoneNumberRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current user is the creator
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Hutopy.Infrastructure.YouTube;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePresentationInfosRequest(
|
||||
Guid CreatorId,
|
||||
string Description,
|
||||
string? VideoUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangePresentationInfosRequestValidator : Validator<ChangePresentationInfosRequest>
|
||||
{
|
||||
public ChangePresentationInfosRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.NotEmpty()
|
||||
.WithMessage("Description is required")
|
||||
.MaximumLength(2000)
|
||||
.WithMessage("Description cannot exceed 2000 characters");
|
||||
|
||||
RuleFor(x => x.VideoUrl)
|
||||
.Must(url => url == null || YouTubeUrlHelper.IsValidYouTubeUrlOrId(url))
|
||||
.WithMessage("Invalid YouTube URL or video ID format");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePresentationInfosHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangePresentationInfosRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/presentation-infos");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePresentationInfosRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the presentation info with the new values
|
||||
creator.Presentation.Description = request.Description.Trim();
|
||||
creator.Presentation.VideoUrl = request.VideoUrl != null
|
||||
? YouTubeUrlHelper.ExtractVideoId(request.VideoUrl.Trim())
|
||||
: null;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeSlugRequest(
|
||||
Guid CreatorId,
|
||||
Guid SlugReservationId);
|
||||
|
||||
[PublicAPI]
|
||||
internal sealed class ChangeSlugRequestValidator
|
||||
: Validator<ChangeSlugRequest>
|
||||
{
|
||||
public ChangeSlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull().WithMessage("You should specify the CreatorId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
|
||||
|
||||
RuleFor(r => r.SlugReservationId)
|
||||
.NotNull().WithMessage("You should specify the SlugReservationId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty SlugReservationId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeSlugHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeSlugRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/creators/{CreatorId}/slug");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeSlugRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Slugs? reservation = await context
|
||||
.Slugs
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.Id == request.SlugReservationId,
|
||||
ct);
|
||||
|
||||
if (reservation is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Slugs? previousReservation = await context
|
||||
.Slugs
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.UsedBy == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (previousReservation is null)
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Remove(previousReservation);
|
||||
reservation.UsedBy = creator.Id;
|
||||
creator.Slug = reservation.NormalizedName;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeSocialsRequest(
|
||||
Guid CreatorId,
|
||||
string? FacebookUrl,
|
||||
string? InstagramUrl,
|
||||
string? XUrl,
|
||||
string? LinkedInUrl,
|
||||
string? TikTokUrl,
|
||||
string? YoutubeUrl,
|
||||
string? RedditUrl,
|
||||
string? WebsiteUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeSocialsHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeSocialsRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/socials");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Socials)
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Socials.FacebookUrl = request.FacebookUrl;
|
||||
creator.Socials.InstagramUrl = request.InstagramUrl;
|
||||
creator.Socials.XUrl = request.XUrl;
|
||||
creator.Socials.LinkedInUrl = request.LinkedInUrl;
|
||||
creator.Socials.TikTokUrl = request.TikTokUrl;
|
||||
creator.Socials.YoutubeUrl = request.YoutubeUrl;
|
||||
creator.Socials.RedditUrl = request.RedditUrl;
|
||||
creator.Socials.WebsiteUrl = request.WebsiteUrl;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeTitleRequest(
|
||||
Guid CreatorId,
|
||||
string? Title);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeTitleHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeTitleRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/title");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeTitleRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Title = request.Title;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CheckStatusStripeResponse(
|
||||
bool IsStripeAccountPresent,
|
||||
bool IsStripeOnboardingComplete,
|
||||
bool IsStripeChargesEnabled,
|
||||
bool IsStripePayoutReady
|
||||
);
|
||||
|
||||
public class CheckStatusStripeIdHandler(
|
||||
IOptionsSnapshot<StripeOptions> stripeOptions,
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest<CheckStatusStripeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe/check-status");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's information
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
|
||||
// 2. Get or create the creator
|
||||
Creator? creator = await dbContext.Creators.SingleOrDefaultAsync(c => c.Id == creatorId, ct);
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. The Creator is not being onboarded
|
||||
if (string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Update Creator's stripe account information
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
AccountService accountService = new();
|
||||
Account? account = await accountService.GetAsync(creator.StripeAccountId, cancellationToken: ct);
|
||||
creator.IsStripePayoutReady = account.PayoutsEnabled;
|
||||
creator.IsStripeChargesEnabled = account.ChargesEnabled;
|
||||
creator.IsStripeDetailsSubmitted = account.DetailsSubmitted;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// 6. Return the account link URL to the client
|
||||
await SendOkAsync(
|
||||
new CheckStatusStripeResponse(
|
||||
creator.StripeAccountId != null,
|
||||
creator.IsStripeDetailsSubmitted,
|
||||
creator.IsStripeChargesEnabled,
|
||||
creator.IsStripePayoutReady
|
||||
),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ConnectStripeResponse(
|
||||
string Url);
|
||||
|
||||
public class ConnectStripeIdHandler(
|
||||
IOptionsSnapshot<WebsiteOptions> websiteOptions,
|
||||
IOptionsSnapshot<StripeOptions> stripeOptions,
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest<ConnectStripeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe/connect");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's information
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
string email = HttpContext.User.GetEmail();
|
||||
|
||||
// 2. Get or create the creator
|
||||
Creator? creator = await dbContext
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == creatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Create a Stripe account
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
AccountService accountService = new();
|
||||
if (string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
Account? account = await accountService.CreateAsync(
|
||||
new AccountCreateOptions
|
||||
{
|
||||
Type = "express",
|
||||
Capabilities = new AccountCapabilitiesOptions
|
||||
{
|
||||
CardPayments = new AccountCapabilitiesCardPaymentsOptions { Requested = true },
|
||||
Transfers = new AccountCapabilitiesTransfersOptions { Requested = true }
|
||||
},
|
||||
Email = email
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Update the creator's Stripe account ID
|
||||
creator.StripeAccountId = account.Id;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// 4. Check if the creator already has a Stripe account
|
||||
if (creator is { IsStripeDetailsSubmitted: true, IsStripeChargesEnabled: true, IsStripePayoutReady: true })
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Create an account link
|
||||
AccountLinkService accountLinkService = new();
|
||||
AccountLink? accountLink = await accountLinkService.CreateAsync(
|
||||
new AccountLinkCreateOptions
|
||||
{
|
||||
Account = creator.StripeAccountId,
|
||||
RefreshUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=retry",
|
||||
ReturnUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=complete",
|
||||
Type = "account_onboarding"
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// 6. Return the account link URL to the client
|
||||
await SendOkAsync(new ConnectStripeResponse(accountLink.Url), ct);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateCreatorRequest(
|
||||
Guid SlugReservationId,
|
||||
Guid CreatorId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
|
||||
{
|
||||
public CreateCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.SlugReservationId)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid SlugReservationId");
|
||||
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<CreateCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
Slugs slug = await context
|
||||
.Slugs
|
||||
.SingleAsync(s => s.Id == req.SlugReservationId, ct);
|
||||
|
||||
if (slug.UsedBy is not null
|
||||
|| slug.ReservedUntil < DateTimeOffset.UtcNow
|
||||
|| slug.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendErrorsAsync(500, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
slug.UsedBy = req.CreatorId;
|
||||
|
||||
await context.Creators.AddAsync(
|
||||
new Creator
|
||||
{
|
||||
Id = req.CreatorId, CreatedBy = User.GetUserId(), Name = slug.Name, Slug = slug.NormalizedName
|
||||
},
|
||||
ct);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorByIdRequest
|
||||
{
|
||||
public required Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class GetCreatorByIdRequestValidator
|
||||
: Validator<GetCreatorByIdRequest>
|
||||
{
|
||||
public GetCreatorByIdRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull().WithMessage("You should specify the CreatorId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorByIdHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<GetCreatorByIdRequest, Creator>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/{CreatorId}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetCreatorByIdRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.FindAsync(
|
||||
[req.CreatorId],
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(creator, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorBySlugRequest
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record GetCreatorBySlugResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; init; }
|
||||
public DateTimeOffset? DeletedAt { get; init; }
|
||||
public bool IsDeleted { get; init; }
|
||||
public bool Verified { get; init; }
|
||||
public bool AcceptDonation { get; init; }
|
||||
public string? BannerUrl { get; init; }
|
||||
public string? PortraitUrl { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public Socials? Socials { get; init; }
|
||||
public Presentation? Presentation { get; init; }
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class GetCreatorBySlugRequestValidator
|
||||
: Validator<GetCreatorBySlugRequest>
|
||||
{
|
||||
public GetCreatorBySlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Name)
|
||||
.NotNull().WithMessage("You should specify the Name")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorBySlugHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<GetCreatorBySlugRequest, GetCreatorBySlugResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/@{Name}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetCreatorBySlugRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorName = req.Name.ToLower();
|
||||
|
||||
GetCreatorBySlugResponse? response = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorName))
|
||||
.AsNoTracking()
|
||||
.Select(c => new GetCreatorBySlugResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedBy = c.CreatedBy,
|
||||
CreatedAt = c.CreatedAt,
|
||||
DeletedBy = c.DeletedBy,
|
||||
DeletedAt = c.DeletedAt,
|
||||
IsDeleted = c.IsDeleted,
|
||||
Verified = c.Verified,
|
||||
BannerUrl = c.BannerUrl,
|
||||
PortraitUrl = c.PortraitUrl,
|
||||
Slug = c.Slug,
|
||||
Name = c.Name,
|
||||
Title = c.Title,
|
||||
AcceptDonation = c.IsStripeChargesEnabled && c.IsStripePayoutReady,
|
||||
Socials = c.Socials,
|
||||
Presentation = c.Presentation
|
||||
})
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isOwner = User.Identity?.IsAuthenticated == true
|
||||
&& User.GetUserId() == response.CreatedBy;
|
||||
|
||||
if (response.IsDeleted && !isOwner)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorProfileResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public Guid? DeletedBy { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public bool IsStripeAccountPresent { get; set; }
|
||||
public bool IsStripeDetailsSubmitted { get; set; }
|
||||
public bool IsStripePayoutReady { get; set; }
|
||||
public bool IsStripeChargesEnabled { get; set; }
|
||||
public required Presentation Presentation { get; set; }
|
||||
public required Socials Socials { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorProfileHandler(
|
||||
CreatorsDbContext context)
|
||||
: EndpointWithoutRequest<GetCreatorProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/profile");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
GetCreatorProfileResponse? creator = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => c.Id == HttpContext.User.GetUserId())
|
||||
.AsNoTracking()
|
||||
.Select(c => new GetCreatorProfileResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedBy = c.CreatedBy,
|
||||
CreatedAt = c.CreatedAt,
|
||||
DeletedBy = c.DeletedBy,
|
||||
DeletedAt = c.DeletedAt,
|
||||
IsDeleted = c.IsDeleted,
|
||||
Name = c.Name,
|
||||
Slug = c.Slug,
|
||||
Title = c.Title,
|
||||
Verified = c.Verified,
|
||||
IsStripeAccountPresent = !string.IsNullOrWhiteSpace(c.StripeAccountId),
|
||||
IsStripeDetailsSubmitted = c.IsStripeDetailsSubmitted,
|
||||
IsStripeChargesEnabled = c.IsStripeChargesEnabled,
|
||||
IsStripePayoutReady = c.IsStripePayoutReady,
|
||||
Presentation = c.Presentation,
|
||||
Socials = c.Socials
|
||||
})
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(creator, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemoveCreatorRequest(
|
||||
string CreatorSlug);
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class RemoveCreatorRequestValidator : Validator<RemoveCreatorRequest>
|
||||
{
|
||||
public RemoveCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorSlug)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorSlug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemoveCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<RemoveCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/creators/@{CreatorSlug}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemoveCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.DeletedAt = DateTimeOffset.UtcNow;
|
||||
creator.DeletedBy = User.GetUserId();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Configuration;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Hutopy.Modules.Creators.Services;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ReserveSlugRequest
|
||||
{
|
||||
public required Guid ReservationId { get; set; }
|
||||
public string Slug { get; set; } = null!;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ReserveSlugRequestValidator : Validator<ReserveSlugRequest>
|
||||
{
|
||||
public ReserveSlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Slug)
|
||||
.NotEmpty()
|
||||
.NotNull()
|
||||
.WithMessage("You should specify a valid Slug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ReserveSlug(
|
||||
CreatorsDbContext context,
|
||||
IOptions<CreatorOptions> opts,
|
||||
SlugPurger slugPurger)
|
||||
: Endpoint<ReserveSlugRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/@{Slug}/reserve");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ReserveSlugRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// First, purge any expired slugs
|
||||
await slugPurger.PurgeExpiredSlugsAsync(ct);
|
||||
|
||||
Slugs? reservation = await context.Slugs.FirstOrDefaultAsync(
|
||||
s => s.Id == req.ReservationId && s.CreatedBy == User.GetUserId(),
|
||||
ct);
|
||||
|
||||
if (reservation == null)
|
||||
{
|
||||
reservation = new Slugs
|
||||
{
|
||||
Id = req.ReservationId, CreatedBy = User.GetUserId(), CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
context.Slugs.Attach(reservation);
|
||||
context.Entry(reservation).State = EntityState.Added;
|
||||
}
|
||||
|
||||
reservation.Name = req.Slug;
|
||||
reservation.ReservedUntil = DateTimeOffset.UtcNow + opts.Value.SlugReservationDuration;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(new { Message = "Slug reserved." }, ct);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
|
||||
Logger.LogError("Transaction failed: {Message}", e.Message);
|
||||
|
||||
if (e.InnerException is PostgresException innerException)
|
||||
{
|
||||
if (innerException.ConstraintName == "IX_Slugs_NormalizedName")
|
||||
{
|
||||
await SendResultAsync(new ProblemDetails(
|
||||
[
|
||||
new ValidationFailure(nameof(Slugs.Name),
|
||||
"The name is already taken.")
|
||||
],
|
||||
(int)HttpStatusCode.Conflict));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendResultAsync(new ProblemDetails(
|
||||
[
|
||||
new ValidationFailure(nameof(Slugs.Name),
|
||||
e.Message)
|
||||
],
|
||||
(int)HttpStatusCode.Conflict));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RestoreCreatorRequest(
|
||||
string CreatorSlug);
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class RestoreCreatorRequestValidator : Validator<RestoreCreatorRequest>
|
||||
{
|
||||
public RestoreCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorSlug)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorSlug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RestoreCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<RestoreCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/creators/@{CreatorSlug}/restore");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RestoreCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.DeletedAt = null;
|
||||
creator.DeletedBy = null;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public class RemoveStripeHandler(
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/stripe");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's ID from the authenticated user
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
|
||||
// 2. Retrieve the creator from the database
|
||||
Creator? creator = await dbContext
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == creatorId,
|
||||
ct);
|
||||
|
||||
// 3. If the creator doesn't exist or has no Stripe account linked, return 404
|
||||
if (creator is null || string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Remove Stripe configuration
|
||||
creator.StripeAccountId = null;
|
||||
creator.IsStripeDetailsSubmitted = false;
|
||||
creator.IsStripeChargesEnabled = false;
|
||||
creator.IsStripePayoutReady = false;
|
||||
|
||||
// 5. Persist changes
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// 6. Respond with success
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
[Migration("20250609203815_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AcceptDonation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeOnboardingComplete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Creators");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Creators",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
BannerUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
Verified = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
StripeAccountId = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: true),
|
||||
IsStripeOnboardingComplete = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsStripePayoutReady = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsStripeChargesEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AcceptDonation = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Creators", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Slugs",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UsedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, computedColumnSql: "LOWER(\"Name\")", stored: true),
|
||||
ReservedUntil = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Slugs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Presentation",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
VideoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Presentation", x => x.CreatorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Presentation_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Creators",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Socials",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FacebookUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
InstagramUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
XUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
LinkedInUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
TikTokUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
YoutubeUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
RedditUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
WebsiteUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Socials", x => x.CreatorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Socials_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Creators",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Slugs_NormalizedName",
|
||||
schema: "Creators",
|
||||
table: "Slugs",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Presentation",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Slugs",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Socials",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Creators",
|
||||
schema: "Creators");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
[Migration("20250610200446_AddStripe")]
|
||||
partial class AddStripe
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeDetailsSubmitted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStripe : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AcceptDonation",
|
||||
schema: "Creators",
|
||||
table: "Creators");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "IsStripeOnboardingComplete",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
newName: "IsStripeDetailsSubmitted");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "IsStripeDetailsSubmitted",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
newName: "IsStripeOnboardingComplete");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AcceptDonation",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
partial class CreatorsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeDetailsSubmitted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Services;
|
||||
|
||||
public sealed class CreatorLookup(
|
||||
CreatorsDbContext context)
|
||||
: ICreatorLookup
|
||||
{
|
||||
public async Task<CreatorReference?> GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.FirstOrDefaultAsync(c => c.Id == creatorId, cancellationToken);
|
||||
|
||||
return creator is null
|
||||
? null
|
||||
: new CreatorReference(
|
||||
creator.Id,
|
||||
creator.Name,
|
||||
creator.PortraitUrl,
|
||||
creator.IsStripeDetailsSubmitted,
|
||||
creator.IsStripeChargesEnabled,
|
||||
creator.StripeAccountId);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Services;
|
||||
|
||||
public class SlugPurger(CreatorsDbContext context)
|
||||
{
|
||||
private static readonly SemaphoreSlim Semaphore = new(1, 1);
|
||||
private static DateTimeOffset s_lastPurgeTime = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan MinTimeBetweenPurges = TimeSpan.FromSeconds(10);
|
||||
|
||||
public async Task PurgeExpiredSlugsAsync(CancellationToken ct)
|
||||
{
|
||||
// Try to acquire the semaphore
|
||||
if (!await Semaphore.WaitAsync(0, ct))
|
||||
{
|
||||
// Another purge operation is in progress, skip this one
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (now - s_lastPurgeTime < MinTimeBetweenPurges)
|
||||
{
|
||||
// Not enough time has passed since the last purge
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete expired slugs that are not in use
|
||||
await context
|
||||
.Slugs
|
||||
.Where(s => s.ReservedUntil < now && s.UsedBy == null)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
// Update the last purge time regardless of whether we found expired slugs or not
|
||||
s_lastPurgeTime = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Modules.Identity.Configuration;
|
||||
namespace Socialize.Modules.Identity.Configuration;
|
||||
|
||||
public record JwtOptions
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Modules.Identity.Contracts;
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public interface IUserLookup
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace Hutopy.Modules.Identity.Contracts;
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public static class KnownRoles
|
||||
{
|
||||
public const string Administrator = nameof(Administrator);
|
||||
public const string Creator = nameof(Creator);
|
||||
public const string Manager = nameof(Manager);
|
||||
public const string Client = nameof(Client);
|
||||
public const string Provider = nameof(Provider);
|
||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Modules.Identity.Contracts;
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public record UserReference(
|
||||
Guid Id,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
|
||||
public class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options)
|
||||
: IdentityDbContext<User, Role, Guid>(options)
|
||||
{
|
||||
public const string SchemaName = "Identity";
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder
|
||||
modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using Hutopy.Modules.Identity.Models;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Models;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class IdentityService(
|
||||
UserManager userManager,
|
||||
@@ -65,4 +66,23 @@ public class IdentityService(
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class Role : IdentityRole<Guid>
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class User : IdentityUser<Guid>
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public sealed class UserManager(
|
||||
IUserStore<User> store,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
using Hutopy.Modules.Identity.Configuration;
|
||||
using Hutopy.Modules.Identity.Contracts;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Hutopy.Modules.Identity.Services;
|
||||
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 Hutopy.Modules.Identity;
|
||||
namespace Socialize.Modules.Identity;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddIdentityModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddDbContext<IdentityDbContext>(configureAction);
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||
|
||||
@@ -23,10 +21,24 @@ public static class DependencyInjection
|
||||
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<IdentityDbContext>()
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddApiEndpoints()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
@@ -36,6 +48,7 @@ public static class DependencyInjection
|
||||
// Scoped services
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EmailVerificationService>();
|
||||
builder.Services.AddScoped<AccessTokenFactory>();
|
||||
builder.Services.AddScoped<IUserLookup, UserLookup>();
|
||||
|
||||
return builder;
|
||||
@@ -47,9 +60,6 @@ public static class DependencyInjection
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
|
||||
await TrySeedAsync(roleManager);
|
||||
|
||||
@@ -64,10 +74,28 @@ public static class DependencyInjection
|
||||
await roleManager.CreateAsync(administratorRole);
|
||||
}
|
||||
|
||||
Role roleCreator = new(KnownRoles.Creator);
|
||||
if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
|
||||
Role managerRole = new(KnownRoles.Manager);
|
||||
if (roleManager.Roles.All(r => r.Name != managerRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(roleCreator);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeAddressRequest(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeAliasRequest(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeBirthDateRequest(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeEmailRequest(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeFullnameRequest(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePhoneRequest(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePortraitRequest(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Web;
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Infrastructure.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ForgotPasswordRequest(
|
||||
@@ -49,10 +49,10 @@ public class ForgotPasswordHandler(
|
||||
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
|
||||
|
||||
// Create a styled email message
|
||||
string subject = "Reset your Hutopy password";
|
||||
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 Hutopy Password</h1>
|
||||
<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:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Hutopy.Modules.Identity.Models;
|
||||
using System.Security.Claims;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Models;
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserQueryHandler(
|
||||
@@ -26,11 +28,42 @@ public class GetCurrentUserQueryHandler(
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user