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,8 @@
namespace Hutopy.Modules.Creators.Configuration;
public class CreatorOptions
{
public const string ConfigurationSection = "Creators";
public TimeSpan SlugReservationDuration { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Modules.Creators.Contracts;
public record CreatorReference(
Guid Id,
string Name,
string? PortraitUrl,
bool OnboardingComplete,
bool AcceptCharges,
string? StripeAccountId);

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Modules.Creators.Contracts;
public interface ICreatorLookup
{
Task<CreatorReference?> GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,32 @@
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>
/// Softdelete 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!" };
}

View File

@@ -0,0 +1,46 @@
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\")", stored: true);
modelBuilder
.Entity<Slugs>()
.HasIndex(x => x.NormalizedName)
.IsUnique();
modelBuilder
.Entity<Creator>()
.Property(c => c.IsDeleted)
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: 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);
}
}

View File

@@ -0,0 +1,11 @@
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; }
}

View File

@@ -0,0 +1,14 @@
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; }
}

View File

@@ -0,0 +1,15 @@
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; }
}

View File

@@ -0,0 +1,35 @@
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)
{
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<CreatorsDbContext>();
await context.Database.MigrateAsync(cancellationToken: cancellationToken);
return app;
}
}

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

View File

@@ -0,0 +1,221 @@
// <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
}
}
}

View File

@@ -0,0 +1,141 @@
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");
}
}
}

View File

@@ -0,0 +1,218 @@
// <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
}
}
}

View File

@@ -0,0 +1,43 @@
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);
}
}
}

View File

@@ -0,0 +1,215 @@
// <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
}
}
}

View File

@@ -0,0 +1,26 @@
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);
}
}

View File

@@ -0,0 +1,43 @@
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
{
var 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();
}
}
}