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

@@ -28,7 +28,7 @@ jobs:
- name: dotnet build and publish
run: |
cd backend
dotnet publish --configuration Release --artifacts-path ./publish/
dotnet publish --configuration Release --artifacts-path ./publish/ backend.sln
# Deploy to Azure WebApp
- name: Deploy to Azure WebApp

30
Stripe.md Normal file
View File

@@ -0,0 +1,30 @@
# Stripe
## Events Workflow
### Membership
1. checkout.session.completed
- Store StripeSubscriptionId, UserId, CreatorId, TierId
- Save a new Subscription entity with the status "Pending"
2. invoice.payment_succeeded
- Grant access (set Subscription.Active = true or similar)
- Record transaction or set StartDate
- Notify Creator (e.g., new member)
3. customer.subscription.updated
- Update `EndDate = CancelAt ?? CanceledAt`
4. customer.subscription.deleted
- Revoke access
- Mark Subscription as inactive/ended
### Tips
1. checkout.session.completed
- Store TipId, StripeSessionId, TipperId, CreatorId
- PaymentIntentStatus == "paid"
- Status = "Paid"
- Notify creator
- Record transaction

View File

@@ -1,13 +0,0 @@
# These are supported funding model platforms
github: JasonTaylorDev # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
# patreon: # Replace with a single Patreon username
# open_collective: # Replace with a single Open Collective username
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
# liberapay: # Replace with a single Liberapay username
# issuehunt: # Replace with a single IssueHunt username
# otechie: # Replace with a single Otechie username
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,10 @@
namespace Hutopy.Common.Domain;
public abstract class Entity
{
public Guid Id { get; init; }
public Guid CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}

View File

@@ -1,14 +1,12 @@
using System.Text;
using Azure.Identity;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Authentication.Cookies;
using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web;
namespace Hutopy;
public static class DependencyInjection
{
@@ -19,7 +17,7 @@ public static class DependencyInjection
services.AddHttpContextAccessor();
services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>();
.AddDbContextCheck<IdentityDbContext>();
services.AddHttpClient();

View File

@@ -1,10 +0,0 @@
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -1,33 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Hutopy.Web</RootNamespace>
<AssemblyName>Hutopy.Web</AssemblyName>
<TargetFramework>net9.0</TargetFramework>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
<UserSecretsId>de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="FastEndpoints" Version="5.35.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0"/>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0"/>
<PackageReference Include="Azure.Identity" Version="1.13.2"/>
<PackageReference Include="FastEndpoints" Version="5.35.0"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.3"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
Version="9.0.3"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="Stripe.net" Version="47.4.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0"/>
<PackageReference Include="Postmark" Version="5.2.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8"/>
<PackageReference Include="Stripe.net" Version="47.4.0"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Modules\Contents\Migrations\"/>
<Folder Include="Modules\Creators\Migrations\"/>
<Folder Include="Modules\Identity\Migrations\"/>
<Folder Include="Modules\Memberships\Migrations\"/>
<Folder Include="Modules\Messaging\Migrations\"/>
<Folder Include="Modules\Tipping\Migrations\"/>
</ItemGroup>
</Project>

View File

@@ -1,43 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B47-4613-AD01-C85CF28491BD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
Directory.Build.props = Directory.Build.props
global.json = global.json
README.md = README.md
start-infrastructure.sh = start-infrastructure.sh
azure-pipelines.yml = azure-pipelines.yml
update-databases.sh = update-databases.sh
create-sql-scripts.sh = create-sql-scripts.sh
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "src\Web\Web.csproj", "{4E4EE20C-F06A-4A1B-851F-C5577796941C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3CB609D9-5D54-4C11-A371-DAAC8B74E430}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
public static class CommonFileNames
{
public const string ProfilePicture = "profilePicture";
public const string BannerPicture = "bannerPicture";
}

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
public static class ContainerNames
{

View File

@@ -1,6 +1,6 @@
using System.Text;
namespace Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
public static class ContentTypes
{
@@ -39,11 +39,6 @@ public static class ContentTypes
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
if (content.Contains("<!DOCTYPE html>"))
{
return true;
}
return false;
return content.Contains("<!DOCTYPE html>");
}
}

View File

@@ -0,0 +1,32 @@
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage
{
/// <summary>
/// Upload a file to blob storage.
/// </summary>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="stream"></param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Download a file to blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
public static class SubDirectoryNames
{
public const string Profile = "profile";
public const string Contents = "contents";
public const string Albums = "albums";
}

View File

@@ -1,10 +1,11 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Hutopy.Infrastructure.BlobStorage.Contracts;
namespace Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Infrastructure.BlobStorage.Services;
public class AzureBlobStorage
public class AzureBlobStorage : IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
@@ -27,8 +28,12 @@ public class AzureBlobStorage
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
public async Task<string> UploadFileAsync(string containerName, string blobName, Stream stream,
string contentType, CancellationToken ct = default)
public async Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default)
{
// Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
@@ -113,7 +118,9 @@ public class AzureBlobStorage
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName,
public async Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default)
{
try

View File

@@ -1,6 +1,4 @@
using System;
namespace Hutopy.Web.Features.Users;
namespace Hutopy.Infrastructure.Configuration;
public class WebsiteOptions
{

View File

@@ -0,0 +1,40 @@
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Infrastructure.BlobStorage.Services;
using Hutopy.Infrastructure.Configuration;
using Hutopy.Infrastructure.Emailer.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts;
using Hutopy.Infrastructure.Emailer.Services;
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Infrastructure.Payments.Stripe.Services;
using Hutopy.Modules.Memberships.Contracts;
using Hutopy.Modules.Tipping.Contracts;
namespace Hutopy.Infrastructure;
public static class DependencyInjection
{
public static WebApplicationBuilder AddInfrastructureModule(
this WebApplicationBuilder builder)
{
builder.Services.Configure<WebsiteOptions>(
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
builder.Services.AddTransient<ITipProcessor, StripeTipProcessor>();
builder.Services.AddTransient<IMembershipPaymentProcessor, MembershipPaymentProcessor>();
builder.Services.AddTransient<IMembershipCancellationProcessor, MembershipCancellationProcessor>();
builder.Services.AddTransient<IMembershipTierProcessor, MembershipTierProcessor>();
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
builder.Services.Configure<EmailerOptions>(
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
//builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddHttpClient();
return builder;
}
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Infrastructure.Emailer.Configuration;
public class EmailerOptions
{
public const string ConfigurationSection = "Emailer";
public string ApiKey { get; set; } = default!;
public string FromEmail { get; set; } = default!;
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Infrastructure.Emailer.Contracts;
public interface IEmailSender
{
Task SendEmailAsync(string email, string subject, string message);
}

View File

@@ -1,22 +1,15 @@
using System.Threading.Tasks;
using Hutopy.Infrastructure.Emailer.Contracts;
namespace Hutopy.Web.Features.Users.Services;
namespace Hutopy.Infrastructure.Emailer.Services;
public interface IEmailSender
{
Task SendEmailAsync(string email, string subject, string message);
}
public class EmailSender(ILogger<IEmailSender> logger)
public class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender
{
public async Task SendEmailAsync(string email, string subject, string message)
{
try
{
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
// TODO: Implement actual email sending logic
await Task.Delay(1000);
logger.LogInformation("Email sent successfully to {Email}", email);
}

View File

@@ -0,0 +1,37 @@
using Hutopy.Infrastructure.Emailer.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
using PostmarkDotNet;
namespace Hutopy.Infrastructure.Emailer.Services;
public class PostmarkEmailSender : IEmailSender
{
private readonly PostmarkClient _client;
private readonly EmailerOptions _options;
public PostmarkEmailSender(IOptions<EmailerOptions> options)
{
_options = options.Value;
_client = new PostmarkClient(_options.ApiKey);
}
public async Task SendEmailAsync(string email, string subject, string message)
{
PostmarkResponse? sendResult = await _client.SendMessageAsync(new PostmarkMessage
{
From = _options.FromEmail,
To = email,
Subject = subject,
HtmlBody = message,
TrackOpens = true,
MessageStream = "outbound" // Optional: use "broadcast" for bulk
});
if (sendResult.Status != PostmarkStatus.Success)
{
throw new InvalidOperationException(
$"Postmark failed to send email: {sendResult.Message}");
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Hutopy.Infrastructure.Emailer.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
namespace Hutopy.Infrastructure.Emailer.Services;
public class ResendEmailSender : IEmailSender
{
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
private readonly HttpClient _httpClient;
private readonly EmailerOptions _options;
public ResendEmailSender(
IHttpClientFactory httpClientFactory,
IOptions<EmailerOptions> options)
{
_httpClient = httpClientFactory.CreateClient();
_options = options.Value;
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
{
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage };
string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Resend email failed: {response.StatusCode} - {body}");
}
}
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Infrastructure.Payments.Stripe.Configuration;
public class StripeOptions
{
public const string ConfigurationSection = "Stripe";
[Required] public required string SecretKey { get; init; }
[Required] public required string WebhookSecret { get; init; }
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
}

View File

@@ -0,0 +1,28 @@
using Hutopy.Modules.Memberships.Contracts;
using Stripe;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public sealed class MembershipCancellationProcessor
: IMembershipCancellationProcessor
{
public async Task<DateTimeOffset?> CancelAsync(
string subscriptionId,
CancellationToken ct = default)
{
SubscriptionService subscriptionService = new();
// Stripe - Cancel Subscription immediately
// var subscription = await subscriptionService.CancelAsync(
// subscriptionId,
// cancellationToken: ct);
// Stripe - Cancel Subscription AtPeriodEnd
Subscription? subscription = await subscriptionService.UpdateAsync(
subscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true },
cancellationToken: ct);
return subscription.CancelAt ?? subscription.CanceledAt;
}
}

View File

@@ -0,0 +1,65 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Memberships.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public class MembershipPaymentProcessor(
IOptions<StripeOptions> stripeOptions)
: IMembershipPaymentProcessor
{
public async Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
Guid userId,
CreatorReference creatorReference,
Guid tierId,
string priceId,
string successUrl,
string cancelUrl)
{
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions
{
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
});
// Create Checkout Session for the subscription
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
],
Mode = "subscription",
SubscriptionData = new SessionSubscriptionDataOptions
{
ApplicationFeePercent = stripeOptions.Value.HutopyRate,
TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorReference.StripeAccountId }
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "userId", userId.ToString() },
{ "creatorId", creatorReference.Id.ToString() },
{ "creatorName", creatorReference.Name },
{ "tierId", tierId.ToString() }
}
});
return new MembershipCheckoutSession(
session.Id,
session.Url);
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Memberships.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public sealed class MembershipTierProcessor(
IOptions<StripeOptions> stripeOptions)
: IMembershipTierProcessor
{
public async Task<string> CreateAsync(
Guid creatorId,
Guid tierId,
string productName,
string currencyCode,
decimal amount)
{
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create the product
var productService = new ProductService();
var product = await productService.CreateAsync(
new ProductCreateOptions
{
Name = productName,
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
});
// Create the price for the product
var priceService = new PriceService();
await priceService.CreateAsync(
new PriceCreateOptions
{
Product = product.Id,
UnitAmountDecimal = amount * 100, // Convert amount to cents
Currency = currencyCode,
Recurring = new PriceRecurringOptions { Interval = "month" }
});
return product.Id;
}
}

View File

@@ -0,0 +1,78 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Tipping.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public class StripeTipProcessor(
IOptions<StripeOptions> stripeOptions)
: ITipProcessor
{
public async Task<TipCheckoutSession> CreateCheckoutSessionAsync(
Guid tipId,
CreatorReference creator,
decimal amount,
string currency,
string message,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions(),
cancellationToken: ct);
// Create paymentIntent for the user
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(
new SessionCreateOptions
{
ClientReferenceId = tipId.ToString(),
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currency,
UnitAmountDecimal = amount, // Amount in cents
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = $"Tip for {creator.Name}", // or any descriptive name for the tip
Metadata = new Dictionary<string, string> { { "creatorId", creator.Id.ToString() } }
}
},
Quantity = 1
}
],
Mode = "payment",
PaymentIntentData = new SessionPaymentIntentDataOptions
{
ApplicationFeeAmount =
Convert.ToInt64(amount * 100 * stripeOptions.Value.HutopyRate), // Platform fee
TransferData = new SessionPaymentIntentDataTransferDataOptions
{
Destination = creator.StripeAccountId // Creator's Stripe account ID
}
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message },
}
},
cancellationToken: ct);
return new TipCheckoutSession(session.Id, session.Url);
}
}

View File

@@ -1,6 +1,6 @@
using System.Security.Claims;
namespace Hutopy.Web.Common.Security;
namespace Hutopy.Infrastructure.Security;
public static class ClaimsPrincipalExtensions
{
@@ -43,8 +43,7 @@ public static class ClaimsPrincipalExtensions
{
var claim = claims.FindFirst(key);
if (claim is null) return default;
return claims.GetRequiredClaim<TValue>(key);
return claim is null ? null : claims.GetRequiredClaim<TValue>(key);
}
private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
@@ -53,11 +52,6 @@ public static class ClaimsPrincipalExtensions
if (claim is null) throw new MissingClaimException(key);
if (typeof(TValue) == typeof(Guid))
{
return Guid.Parse(claim.Value);
}
return Convert.ChangeType(claim.Value, typeof(TValue));
return typeof(TValue) == typeof(Guid) ? Guid.Parse(claim.Value) : Convert.ChangeType(claim.Value, typeof(TValue));
}
}

View File

@@ -3,7 +3,7 @@ using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web.Common.Security;
namespace Hutopy.Infrastructure.Security;
public static class JwtTokenHelper
{
@@ -22,23 +22,22 @@ public static class JwtTokenHelper
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>(new[]
{
var claims = new List<Claim>([
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Email, email),
new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.GivenName, firstname),
new Claim(ClaimTypes.Surname, lastname)
});
]);
if (alias is not null)
{
claims.Add(new(KnownClaims.Alias, alias));
claims.Add(new Claim(KnownClaims.Alias, alias));
}
if (portraitUrl is not null)
{
claims.Add(new(KnownClaims.PortraitUrl, portraitUrl));
claims.Add(new Claim(KnownClaims.PortraitUrl, portraitUrl));
}
var token = new JwtSecurityToken(

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Common.Security;
namespace Hutopy.Infrastructure.Security;
public static class KnownClaims
{

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Infrastructure.Security;
public class MissingClaimException(
string claimName)
: Exception($"Claim '{claimName}' is missing.");

View File

@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
namespace Hutopy.Web.Common.Security;
namespace Hutopy.Infrastructure.Security;
// If we need to add special characters we can alternate between 2 pools.
public static class PasswordGenerator

View File

@@ -1,14 +1,13 @@
using System.Security.Cryptography;
namespace Hutopy.Web.Common.Security;
namespace Hutopy.Infrastructure.Security;
public static class RefreshTokenGenerator
{
public static string Next()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
RandomNumberGenerator.Fill(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace Hutopy.Web.Common.YouTube;
namespace Hutopy.Infrastructure.YouTube;
public static class YouTubeUrlHelper
{

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Common.Domain;
namespace Hutopy.Modules.Contents.Data;
public class Album : Entity
{
public bool IsDeleted { get; private set; } // private set → EF updates it
[MaxLength(255)] public required string Title { get; set; }
public IList<AlbumPhoto> Photos { get; set; } = new List<AlbumPhoto>();
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Common.Domain;
namespace Hutopy.Modules.Contents.Data;
public class AlbumPhoto : Entity
{
public bool IsDeleted { get; private set; } // private set → EF updates it
public Guid AlbumId { get; set; }
public Album Album { get; init; } = null!;
[MaxLength(2048)] public required string OriginalUrl { get; set; }
[MaxLength(2048)] public required string ThumbnailUrl { get; set; }
[MaxLength(256)] public string? Caption { get; set; }
public int Order { get; set; }
}

View File

@@ -0,0 +1,56 @@
namespace Hutopy.Modules.Contents.Data;
public class ContentsDbContext(
DbContextOptions<ContentsDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Content";
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumPhoto> AlbumPhotos => Set<AlbumPhoto>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
// Album configuration
modelBuilder
.Entity<Album>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Album>()
.Property(c => c.IsDeleted)
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true);
modelBuilder
.Entity<Album>()
.HasQueryFilter(a => !a.IsDeleted);
// AlbumPhoto configuration
modelBuilder
.Entity<AlbumPhoto>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<AlbumPhoto>()
.Property(c => c.IsDeleted)
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", stored: true);
modelBuilder
.Entity<AlbumPhoto>()
.HasOne(ap => ap.Album)
.WithMany(a => a.Photos)
.HasForeignKey(ap => ap.AlbumId)
.IsRequired();
modelBuilder
.Entity<AlbumPhoto>()
.HasQueryFilter(ap => !ap.IsDeleted);
}
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Modules.Contents.Data;
namespace Hutopy.Modules.Contents;
public static class DependencyInjection
{
public static WebApplicationBuilder AddContentModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<ContentsDbContext>(configureAction);
return builder;
}
public static async Task<IApplicationBuilder> UseContentModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<ContentsDbContext>();
await context.Database.MigrateAsync(cancellationToken);
return app;
}
}

View File

@@ -1,10 +1,10 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Contents.Data;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Contents.Features;
[PublicAPI]
public record AddPhotoToAlbumRequest(
@@ -24,12 +24,12 @@ public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumR
{
private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
private static readonly string[] AllowedImageTypes =
{
[
"image/jpeg",
"image/png",
"image/gif",
"image/webp"
};
];
public AddPhotoToAlbumRequestValidator()
{
@@ -56,8 +56,8 @@ public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumR
[PublicAPI]
public class AddPhotoToAlbumHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
ContentsDbContext context,
IBlobStorage blobStorage)
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
{
private const int MaxThumbnailWidth = 500;
@@ -133,7 +133,7 @@ public class AddPhotoToAlbumHandler(
{
await SendStringAsync("Invalid image format", 400, cancellation: ct);
}
catch (Exception ex)
catch (Exception)
{
await SendStringAsync("Error processing image", 500, cancellation: ct);
}

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Contents.Features;
[PublicAPI]
public record CreateAlbumRequest(
@@ -34,7 +34,7 @@ public sealed class CreateAlbumRequestValidator : Validator<CreateAlbumRequest>
[PublicAPI]
public class CreateAlbumHandler(
ContentDbContext context)
ContentsDbContext context)
: Endpoint<CreateAlbumRequest, CreateAlbumResponse>
{
public override void Configure()

View File

@@ -1,10 +1,6 @@
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Modules.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Contents.Features;
[PublicAPI]
public record GetAlbumRequest(
@@ -39,7 +35,7 @@ public sealed class GetAlbumRequestValidator : Validator<GetAlbumRequest>
[PublicAPI]
public class GetAlbumHandler(
ContentDbContext context)
ContentsDbContext context)
: Endpoint<GetAlbumRequest, GetAlbumResponse>
{
public override void Configure()

View File

@@ -1,10 +1,7 @@
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Contents.Features;
[PublicAPI]
public record RemoveAlbumRequest(
@@ -23,7 +20,7 @@ public sealed class RemoveAlbumRequestValidator : Validator<RemoveAlbumRequest>
[PublicAPI]
public class RemoveAlbumHandler(
ContentDbContext context)
ContentsDbContext context)
: Endpoint<RemoveAlbumRequest>
{
public override void Configure()

View File

@@ -1,10 +1,7 @@
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Contents.Features;
[PublicAPI]
public record RemovePhotoFromAlbumRequest(
@@ -28,7 +25,7 @@ public sealed class RemovePhotoFromAlbumRequestValidator : Validator<RemovePhoto
[PublicAPI]
public class RemovePhotoFromAlbumHandler(
ContentDbContext context)
ContentsDbContext context)
: Endpoint<RemovePhotoFromAlbumRequest>
{
public override void Configure()

View File

@@ -0,0 +1,134 @@
// <auto-generated />
using System;
using Hutopy.Modules.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Contents.Migrations
{
[DbContext(typeof(ContentsDbContext))]
[Migration("20250609212411_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Albums", "Content");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AlbumId")
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("OriginalUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("AlbumPhotos", "Content");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
{
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
.WithMany("Photos")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -3,28 +3,29 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
namespace Hutopy.Modules.Contents.Migrations
{
/// <inheritdoc />
public partial class AddAlbumAndPhotos : Migration
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Content");
migrationBuilder.CreateTable(
name: "Albums",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
CoverPhotoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true)
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
@@ -37,15 +38,16 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
AlbumId = table.Column<Guid>(type: "uuid", nullable: false),
OriginalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
ThumbnailUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Caption = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Order = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
AlbumId = table.Column<Guid>(type: "uuid", nullable: false),
PhotoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Caption = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Order = table.Column<int>(type: "integer", nullable: false)
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{

View File

@@ -0,0 +1,131 @@
// <auto-generated />
using System;
using Hutopy.Modules.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Contents.Migrations
{
[DbContext(typeof(ContentsDbContext))]
partial class ContentsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Albums", "Content");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AlbumId")
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("OriginalUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("AlbumPhotos", "Content");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
{
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
.WithMany("Photos")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
{
b.Navigation("Photos");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Modules.Contents.Models;
[PublicAPI]
public class ContentModel
@@ -15,5 +15,4 @@ public class ContentModel
public string HtmlFileUrl { get; init; } = "";
public required string[]? Urls { get; init; }
public string? ThumbnailUrl { get; init; }
public IList<ReactionModel>? Reactions { get; set; } = new List<ReactionModel>();
}

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Modules.Contents.Models;
[PublicAPI]
public record FollowModel(

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

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Modules.Creators.Data;
public class Slugs
{

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

@@ -1,10 +1,7 @@
using FluentValidation;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeEmailRequest(
@@ -28,7 +25,7 @@ public sealed class ChangeEmailRequestValidator : Validator<ChangeEmailRequest>
[PublicAPI]
public class ChangeEmailHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeLogoRequest(
@@ -29,8 +29,8 @@ public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
[PublicAPI]
public class ChangeLogoHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
CreatorsDbContext context,
IBlobStorage blobStorage)
: Endpoint<ChangeLogoRequest>
{
public override void Configure()

View File

@@ -1,6 +1,6 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeNameRequest(
@@ -21,7 +21,7 @@ internal sealed class ChangeNameRequestValidator
[PublicAPI]
public class ChangeNameHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangeNameRequest>
{
public override void Configure()

View File

@@ -1,10 +1,7 @@
using FluentValidation;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangePhoneNumberRequest(
@@ -28,7 +25,7 @@ public sealed class ChangePhoneNumberRequestValidator : Validator<ChangePhoneNum
[PublicAPI]
public class ChangePhoneNumberHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangePhoneNumberRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.YouTube;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.YouTube;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangePresentationInfosRequest(
@@ -32,7 +32,7 @@ public sealed class ChangePresentationInfosRequestValidator : Validator<ChangePr
[PublicAPI]
public class ChangePresentationInfosHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangePresentationInfosRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeSlugRequest(
@@ -26,7 +26,7 @@ internal sealed class ChangeSlugRequestValidator
[PublicAPI]
public class ChangeSlugHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangeSlugRequest>
{
public override void Configure()

View File

@@ -1,6 +1,6 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeSocialsRequest(
@@ -16,7 +16,7 @@ public record ChangeSocialsRequest(
[PublicAPI]
public class ChangeSocialsHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangeSocialsRequest>
{
public override void Configure()

View File

@@ -1,6 +1,6 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ChangeTitleRequest(
@@ -9,7 +9,7 @@ public record ChangeTitleRequest(
[PublicAPI]
public class ChangeTitleHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<ChangeTitleRequest>
{
public override void Configure()

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

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record CreateCreatorRequest(
@@ -27,7 +27,7 @@ public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorReque
[PublicAPI]
public sealed class CreateCreatorHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<CreateCreatorRequest>
{
public override void Configure()
@@ -74,7 +74,7 @@ public sealed class CreateCreatorHandler(
await SendOkAsync(ct);
}
catch (Exception e)
catch (Exception)
{
await transaction.RollbackAsync(ct);
}

View File

@@ -1,6 +1,6 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public sealed class GetCreatorByIdRequest
@@ -22,7 +22,7 @@ public sealed class GetCreatorByIdRequestValidator
[PublicAPI]
public class GetCreatorByIdHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<GetCreatorByIdRequest, Creator>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public sealed class GetCreatorBySlugRequest
@@ -22,11 +22,11 @@ public record GetCreatorBySlugResponse
public bool AcceptDonation { get; init; }
public string? BannerUrl { get; init; }
public string? PortraitUrl { get; init; }
public string Slug { get; init; }
public string Name { 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; }
public Socials? Socials { get; init; }
public Presentation? Presentation { get; init; }
}
[UsedImplicitly]
@@ -43,7 +43,7 @@ public sealed class GetCreatorBySlugRequestValidator
[PublicAPI]
public class GetCreatorBySlugHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<GetCreatorBySlugRequest, GetCreatorBySlugResponse>
{
public override void Configure()
@@ -73,12 +73,12 @@ public class GetCreatorBySlugHandler(
DeletedAt = c.DeletedAt,
IsDeleted = c.IsDeleted,
Verified = c.Verified,
AcceptDonation = c.AcceptDonation,
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
})

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public sealed class GetCreatorProfileResponse
@@ -16,26 +16,29 @@ public sealed class GetCreatorProfileResponse
public required string Slug { get; set; }
public string? Title { get; set; }
public bool Verified { get; set; }
public bool AcceptDonation { 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(
ContentDbContext context)
CreatorsDbContext context)
: EndpointWithoutRequest<GetCreatorProfileResponse>
{
public override void Configure()
{
Get("/api/creators/profile");
Options((o => o.WithTags("Creators")));
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var creator = await context
GetCreatorProfileResponse? creator = await context
.Creators
.IgnoreQueryFilters()
.Where(c => c.Id == HttpContext.User.GetUserId())
@@ -52,13 +55,22 @@ public class GetCreatorProfileHandler(
Slug = c.Slug,
Title = c.Title,
Verified = c.Verified,
AcceptDonation = c.AcceptDonation,
IsStripeAccountPresent = !string.IsNullOrWhiteSpace(c.StripeAccountId),
IsStripeDetailsSubmitted = c.IsStripeDetailsSubmitted,
IsStripeChargesEnabled = c.IsStripeChargesEnabled,
IsStripePayoutReady = c.IsStripePayoutReady,
Presentation = c.Presentation,
Socials = c.Socials,
Socials = c.Socials
})
.SingleOrDefaultAsync(ct);
if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
}
else
{
await SendAsync(creator, cancellation: ct);
}
}
}

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record RemoveCreatorRequest(
@@ -21,7 +21,7 @@ public sealed class RemoveCreatorRequestValidator : Validator<RemoveCreatorReque
[PublicAPI]
public sealed class RemoveCreatorHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<RemoveCreatorRequest>
{
public override void Configure()

View File

@@ -1,11 +1,13 @@
using System.Net;
using FluentValidation.Results;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
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.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record ReserveSlugRequest
@@ -28,15 +30,15 @@ public sealed class ReserveSlugRequestValidator : Validator<ReserveSlugRequest>
[PublicAPI]
public sealed class ReserveSlug(
ContentDbContext context,
IOptions<ContentOptions> opts,
CreatorsDbContext context,
IOptions<CreatorOptions> opts,
SlugPurger slugPurger)
: Endpoint<ReserveSlugRequest>
{
public override void Configure()
{
Post("/api/creators/@{Slug}/reserve");
Options(o => o.WithTags("Contents"));
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
namespace Hutopy.Modules.Creators.Features;
[PublicAPI]
public record RestoreCreatorRequest(
@@ -21,7 +21,7 @@ public sealed class RestoreCreatorRequestValidator : Validator<RestoreCreatorReq
[PublicAPI]
public sealed class RestoreCreatorHandler(
ContentDbContext context)
CreatorsDbContext context)
: Endpoint<RestoreCreatorRequest>
{
public override void Configure()

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

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -9,75 +9,24 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
namespace Hutopy.Modules.Creators.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20250423153323_AddPresentation")]
partial class AddPresentation
[DbContext(typeof(CreatorsDbContext))]
[Migration("20250609203815_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasDefaultSchema("Creators")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.PrimitiveCollection<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -107,10 +56,19 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.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(255)
.HasColumnType("character varying(255)");
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
@@ -121,19 +79,23 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeAccountId")
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("Creators", "Content");
b.ToTable("Creators", "Creators");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Slugs", b =>
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -155,7 +117,7 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true);
.HasComputedColumnSql("LOWER(\"Name\")", true);
b.Property<DateTimeOffset>("ReservedUntil")
.HasColumnType("timestamp with time zone");
@@ -168,69 +130,27 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Slugs", "Content");
b.ToTable("Slugs", "Creators");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId");
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Presentation", "Presentation", b1 =>
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
.HasColumnType("text");
b1.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.Property<string>("PhoneNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.Property<string>("VideoUrl")
.HasMaxLength(2048)
@@ -238,13 +158,13 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b1.HasKey("CreatorId");
b1.ToTable("Presentation", "Content");
b1.ToTable("Presentation", "Creators");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
@@ -283,7 +203,7 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.ToTable("Socials", "Creators");
b1.WithOwner()
.HasForeignKey("CreatorId");

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

@@ -2,30 +2,42 @@
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
namespace Hutopy.Modules.Creators.Migrations
{
/// <inheritdoc />
public partial class Adds_AcceptDonation_Creator : Migration
public partial class AddStripe : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
migrationBuilder.DropColumn(
name: "AcceptDonation",
schema: "Content",
schema: "Creators",
table: "Creators");
migrationBuilder.RenameColumn(
name: "IsStripeOnboardingComplete",
schema: "Creators",
table: "Creators",
type: "boolean",
nullable: false,
defaultValue: false);
newName: "IsStripeDetailsSubmitted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
migrationBuilder.RenameColumn(
name: "IsStripeDetailsSubmitted",
schema: "Creators",
table: "Creators",
newName: "IsStripeOnboardingComplete");
migrationBuilder.AddColumn<bool>(
name: "AcceptDonation",
schema: "Content",
table: "Creators");
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

@@ -1,15 +1,17 @@
namespace Hutopy.Web.Features.Contents.Data;
using Hutopy.Modules.Creators.Data;
public class SlugPurger(ContentDbContext context)
namespace Hutopy.Modules.Creators.Services;
public class SlugPurger(CreatorsDbContext context)
{
private static readonly SemaphoreSlim _semaphore = new(1, 1);
private static DateTimeOffset _lastPurgeTime = DateTimeOffset.MinValue;
private static readonly TimeSpan _minTimeBetweenPurges = TimeSpan.FromSeconds(10);
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))
if (!await Semaphore.WaitAsync(0, ct))
{
// Another purge operation is in progress, skip this one
return;
@@ -18,7 +20,7 @@ public class SlugPurger(ContentDbContext context)
try
{
var now = DateTimeOffset.UtcNow;
if (now - _lastPurgeTime < _minTimeBetweenPurges)
if (now - s_lastPurgeTime < MinTimeBetweenPurges)
{
// Not enough time has passed since the last purge
return;
@@ -31,11 +33,11 @@ public class SlugPurger(ContentDbContext context)
.ExecuteDeleteAsync(ct);
// Update the last purge time regardless of whether we found expired slugs or not
_lastPurgeTime = now;
s_lastPurgeTime = now;
}
finally
{
_semaphore.Release();
Semaphore.Release();
}
}
}

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Users;
namespace Hutopy.Modules.Identity.Configuration;
public record JwtOptions
{

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Modules.Identity.Contracts;
public interface IUserLookup
{
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Users;
namespace Hutopy.Modules.Identity.Contracts;
public static class KnownRoles
{

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Modules.Identity.Contracts;
public record UserReference(
Guid Id,
string Fullname,
string? PortraitUrl);

View File

@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
namespace Hutopy.Web.Features.Users.Data
namespace Hutopy.Modules.Identity.Data
{
public class ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options)
: IdentityDbContext<IdentityUser, IdentityRole, Guid>(options)
public class IdentityDbContext(
DbContextOptions<IdentityDbContext> options)
: IdentityDbContext<User, Role, Guid>(options)
{
public const string SchemaName = "Identity";
protected override void OnModelCreating(ModelBuilder
modelBuilder)
{

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using Hutopy.Modules.Identity.Models;
namespace Hutopy.Modules.Identity.Data;
public class IdentityService(
UserManager userManager,
IHttpContextAccessor contextAccessor
)
{
public async Task<UserModel?> GetCurrentUserAsync()
{
var currentUserId = contextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(currentUserId))
{
return null;
}
UserModel? ret;
var user = await userManager.FindByIdAsync(currentUserId);
if (user == null) ret = null;
else
{
var userModel = new UserModel
{
Id = user.Id,
Username = user.UserName ?? string.Empty,
PhoneNumber = user.PhoneNumber ?? string.Empty,
Email = user.Email ?? string.Empty,
PortraitUrl = user.PortraitUrl,
Alias = user.Alias,
Firstname = user.Firstname,
Lastname = user.Lastname,
BirthDate = user.BirthDate,
Address = user.Address
};
ret = userModel;
}
return ret;
}
public async Task<IList<string>> GetCurrentUserRolesAsync()
{
var currentUserModel = await GetCurrentUserAsync();
if (currentUserModel is null) return [];
var currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
if (currentUser is null) return [];
var userRoles = await userManager.GetRolesAsync(currentUser);
return userRoles;
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity.Data;
public class Role : IdentityRole<Guid>
{
public Role() { }
public Role(string roleName) : base(roleName) { }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity.Data;
public class User : IdentityUser<Guid>
{
[MaxLength(256)] public string? Alias { get; set; }
[MaxLength(256)] public string? Firstname { get; set; }
[MaxLength(256)] public string? Lastname { get; set; }
public DateTime? BirthDate { get; set; }
[MaxLength(256)] public string? Address { get; set; }
[MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public string Fullname => $"{Lastname}, {Firstname}";
}

View File

@@ -1,19 +1,19 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Hutopy.Web.Features.Users.Data;
namespace Hutopy.Modules.Identity.Data;
public sealed class IdentityUserManager(
IUserStore<IdentityUser> store,
public sealed class UserManager(
IUserStore<User> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<IdentityUser> passwordHasher,
IEnumerable<IUserValidator<IdentityUser>> userValidators,
IEnumerable<IPasswordValidator<IdentityUser>> passwordValidators,
IPasswordHasher<User> passwordHasher,
IEnumerable<IUserValidator<User>> userValidators,
IEnumerable<IPasswordValidator<User>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<IdentityUser>> logger)
: UserManager<IdentityUser>(
ILogger<UserManager<User>> logger)
: UserManager<User>(
store,
optionsAccessor,
passwordHasher,

View File

@@ -0,0 +1,72 @@
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Contracts;
using Hutopy.Modules.Identity.Data;
using Hutopy.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity;
public static class DependencyInjection
{
public static WebApplicationBuilder AddIdentityModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<IdentityDbContext>(configureAction);
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddAuthorizationBuilder();
builder.Services
.AddIdentityCore<User>()
.AddUserManager<UserManager>()
.AddRoles<Role>()
.AddEntityFrameworkStores<IdentityDbContext>()
.AddApiEndpoints()
.AddDefaultTokenProviders();
// Singleton services
builder.Services.AddSingleton(TimeProvider.System);
// Scoped services
builder.Services.AddScoped<IdentityService>();
builder.Services.AddTransient<IUserLookup, UserLookup>();
return builder;
}
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
await context.Database.MigrateAsync(cancellationToken: cancellationToken);
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
await TrySeedAsync(roleManager);
return app;
}
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
{
var administratorRole = new Role(KnownRoles.Administrator);
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
{
await roleManager.CreateAsync(administratorRole);
}
var roleCreator = new Role(KnownRoles.Creator);
if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
{
await roleManager.CreateAsync(roleCreator);
}
}
}

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Web.Features.Users.Handlers;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
@@ -9,7 +9,7 @@ public record ChangeAddressRequest(
[PublicAPI]
public class ChangeAddressHandler(
IdentityUserManager userManager)
UserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Web.Features.Users.Handlers;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAliasRequest(
@@ -9,7 +9,7 @@ public record ChangeAliasRequest(
[PublicAPI]
public class ChangeAliasHandler(
IdentityUserManager userManager)
UserManager userManager)
: Endpoint<ChangeAliasRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Web.Features.Users.Handlers;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeBirthDateRequest(
@@ -9,7 +9,7 @@ public record ChangeBirthDateRequest(
[PublicAPI]
public class ChangeBirthDateHandler(
IdentityUserManager userManager)
UserManager userManager)
: Endpoint<ChangeBirthDateRequest>
{
public override void Configure()

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users.Data;
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Data;
namespace Hutopy.Web.Features.Users.Handlers;
namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeEmailRequest(
@@ -9,7 +9,7 @@ public record ChangeEmailRequest(
[PublicAPI]
public class ChangeEmailHandler(
IdentityUserManager userManager)
UserManager userManager)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()

Some files were not shown because too many files have changed in this diff Show More