many fixes and improvements - rework for modules/ and common/

feat(emailer): add Postmark and Resend providers
This commit is contained in:
2025-06-06 12:21:43 -04:00
parent 31ba18fa8d
commit 25b94d3e02
313 changed files with 6586 additions and 18260 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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