many fixes and improvements - rework for modules/ and common/
feat(emailer): add Postmark and Resend providers
This commit is contained in:
60
backend/Modules/Creators/Features/ChangeBanner.cs
Normal file
60
backend/Modules/Creators/Features/ChangeBanner.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
backend/Modules/Creators/Features/ChangeEmail.cs
Normal file
67
backend/Modules/Creators/Features/ChangeEmail.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
74
backend/Modules/Creators/Features/ChangeLogo.cs
Normal file
74
backend/Modules/Creators/Features/ChangeLogo.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
49
backend/Modules/Creators/Features/ChangeName.cs
Normal file
49
backend/Modules/Creators/Features/ChangeName.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: ct);
|
||||
|
||||
creator.Name = request.Name;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
67
backend/Modules/Creators/Features/ChangePhoneNumber.cs
Normal file
67
backend/Modules/Creators/Features/ChangePhoneNumber.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
71
backend/Modules/Creators/Features/ChangePresentationInfos.cs
Normal file
71
backend/Modules/Creators/Features/ChangePresentationInfos.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
97
backend/Modules/Creators/Features/ChangeSlug.cs
Normal file
97
backend/Modules/Creators/Features/ChangeSlug.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
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 var transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var reservation = await context
|
||||
.Slugs
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.Id == request.SlugReservationId,
|
||||
ct);
|
||||
|
||||
if (reservation is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
backend/Modules/Creators/Features/ChangeSocials.cs
Normal file
50
backend/Modules/Creators/Features/ChangeSocials.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Socials)
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
37
backend/Modules/Creators/Features/ChangeTitle.cs
Normal file
37
backend/Modules/Creators/Features/ChangeTitle.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
cancellationToken: ct);
|
||||
|
||||
creator.Title = request.Title;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
68
backend/Modules/Creators/Features/CheckStatusStripe.cs
Normal file
68
backend/Modules/Creators/Features/CheckStatusStripe.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
90
backend/Modules/Creators/Features/ConnectStripe.cs
Normal file
90
backend/Modules/Creators/Features/ConnectStripe.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
82
backend/Modules/Creators/Features/CreateCreator.cs
Normal file
82
backend/Modules/Creators/Features/CreateCreator.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
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 var transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
backend/Modules/Creators/Features/GetCreatorById.cs
Normal file
48
backend/Modules/Creators/Features/GetCreatorById.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
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)
|
||||
{
|
||||
var creator = await context
|
||||
.Creators
|
||||
.FindAsync(
|
||||
[req.CreatorId],
|
||||
cancellationToken: ct);
|
||||
|
||||
if (creator is null) await SendNotFoundAsync(ct);
|
||||
else await SendAsync(creator, cancellation: ct);
|
||||
}
|
||||
}
|
||||
105
backend/Modules/Creators/Features/GetCreatorBySlug.cs
Normal file
105
backend/Modules/Creators/Features/GetCreatorBySlug.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
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)
|
||||
{
|
||||
var creatorName = req.Name.ToLower();
|
||||
|
||||
var 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
backend/Modules/Creators/Features/GetCreatorProfile.cs
Normal file
76
backend/Modules/Creators/Features/GetCreatorProfile.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
backend/Modules/Creators/Features/RemoveCreator.cs
Normal file
63
backend/Modules/Creators/Features/RemoveCreator.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
{
|
||||
var creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
var creator = await context
|
||||
.Creators
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
110
backend/Modules/Creators/Features/ReserveSlug.cs
Normal file
110
backend/Modules/Creators/Features/ReserveSlug.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
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.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 var transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// First, purge any expired slugs
|
||||
await slugPurger.PurgeExpiredSlugsAsync(ct);
|
||||
|
||||
var reservation = await context.Slugs.FirstOrDefaultAsync(
|
||||
s => s.Id == req.ReservationId && s.CreatedBy == User.GetUserId(),
|
||||
cancellationToken: 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
backend/Modules/Creators/Features/RestoreCreator.cs
Normal file
64
backend/Modules/Creators/Features/RestoreCreator.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
{
|
||||
var creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
var creator = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(cancellationToken: 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);
|
||||
}
|
||||
}
|
||||
48
backend/Modules/Creators/Features/RevokeStripe.cs
Normal file
48
backend/Modules/Creators/Features/RevokeStripe.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user