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 - name: dotnet build and publish
run: | run: |
cd backend cd backend
dotnet publish --configuration Release --artifacts-path ./publish/ dotnet publish --configuration Release --artifacts-path ./publish/ backend.sln
# Deploy to Azure WebApp # Deploy to Azure WebApp
- name: 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 System.Text;
using Azure.Identity; using Hutopy.Modules.Identity.Data;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web; namespace Hutopy;
public static class DependencyInjection public static class DependencyInjection
{ {
@@ -19,7 +17,7 @@ public static class DependencyInjection
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddHealthChecks() services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>(); .AddDbContextCheck<IdentityDbContext>();
services.AddHttpClient(); 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"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<RootNamespace>Hutopy.Web</RootNamespace> <TargetFramework>net9.0</TargetFramework>
<AssemblyName>Hutopy.Web</AssemblyName> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
<UserSecretsId>de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85</UserSecretsId> <UserSecretsId>de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.24.0"/>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0"/>
<PackageReference Include="Azure.Identity" Version="1.13.2" /> <PackageReference Include="Azure.Identity" Version="1.13.2"/>
<PackageReference Include="FastEndpoints" Version="5.35.0" /> <PackageReference Include="FastEndpoints" Version="5.35.0"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="9.0.3" /> <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.Google" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.Diagnostics.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.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.AspNetCore.OpenApi" Version="9.0.3"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.3"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0" /> 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"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" /> <PackageReference Include="Postmark" Version="5.2.0"/>
<PackageReference Include="Stripe.net" Version="47.4.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> </ItemGroup>
</Project> </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 public static class ContainerNames
{ {

View File

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

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;
using Azure.Storage.Blobs; using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models; 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 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="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param> /// <param name="ct">The cancellation token</param>
/// <returns></returns> /// <returns></returns>
public async Task<string> UploadFileAsync(string containerName, string blobName, Stream stream, public async Task<string> UploadFileAsync(
string contentType, CancellationToken ct = default) string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default)
{ {
// Read the file stream into a memory stream to determine the length // Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM. // 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="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param> /// <param name="ct">The cancellation token for the request</param>
/// <returns></returns> /// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName, public async Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default) CancellationToken ct = default)
{ {
try try

View File

@@ -1,6 +1,4 @@
using System; namespace Hutopy.Infrastructure.Configuration;
namespace Hutopy.Web.Features.Users;
public class WebsiteOptions 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 public class LoggerEmailSender(ILogger<IEmailSender> logger)
{
Task SendEmailAsync(string email, string subject, string message);
}
public class EmailSender(ILogger<IEmailSender> logger)
: IEmailSender : IEmailSender
{ {
public async Task SendEmailAsync(string email, string subject, string message) public async Task SendEmailAsync(string email, string subject, string message)
{ {
try try
{ {
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject); logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
// TODO: Implement actual email sending logic
await Task.Delay(1000); await Task.Delay(1000);
logger.LogInformation("Email sent successfully to {Email}", email); 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; using System.Security.Claims;
namespace Hutopy.Web.Common.Security; namespace Hutopy.Infrastructure.Security;
public static class ClaimsPrincipalExtensions public static class ClaimsPrincipalExtensions
{ {
@@ -43,8 +43,7 @@ public static class ClaimsPrincipalExtensions
{ {
var claim = claims.FindFirst(key); var claim = claims.FindFirst(key);
if (claim is null) return default; return claim is null ? null : claims.GetRequiredClaim<TValue>(key);
return claims.GetRequiredClaim<TValue>(key);
} }
private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string 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 (claim is null) throw new MissingClaimException(key);
if (typeof(TValue) == typeof(Guid)) return typeof(TValue) == typeof(Guid) ? Guid.Parse(claim.Value) : Convert.ChangeType(claim.Value, typeof(TValue));
{
return Guid.Parse(claim.Value);
}
return Convert.ChangeType(claim.Value, typeof(TValue));
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,7 @@
using FastEndpoints; using Hutopy.Infrastructure.Security;
using JetBrains.Annotations; using Hutopy.Modules.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Modules.Contents.Features;
[PublicAPI] [PublicAPI]
public record RemovePhotoFromAlbumRequest( public record RemovePhotoFromAlbumRequest(
@@ -28,7 +25,7 @@ public sealed class RemovePhotoFromAlbumRequestValidator : Validator<RemovePhoto
[PublicAPI] [PublicAPI]
public class RemovePhotoFromAlbumHandler( public class RemovePhotoFromAlbumHandler(
ContentDbContext context) ContentsDbContext context)
: Endpoint<RemovePhotoFromAlbumRequest> : Endpoint<RemovePhotoFromAlbumRequest>
{ {
public override void Configure() 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 #nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations namespace Hutopy.Modules.Contents.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddAlbumAndPhotos : Migration public partial class Initial : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.EnsureSchema(
name: "Content");
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Albums", name: "Albums",
schema: "Content", schema: "Content",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), 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), CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true), DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", 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)
}, },
constraints: table => constraints: table =>
{ {
@@ -37,15 +38,16 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), 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), CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true), DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", 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)
}, },
constraints: table => 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] [PublicAPI]
public class ContentModel public class ContentModel
@@ -15,5 +15,4 @@ public class ContentModel
public string HtmlFileUrl { get; init; } = ""; public string HtmlFileUrl { get; init; } = "";
public required string[]? Urls { get; init; } public required string[]? Urls { get; init; }
public string? ThumbnailUrl { 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] [PublicAPI]
public record FollowModel( 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; using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data; namespace Hutopy.Modules.Creators.Data;
public class Slugs 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 Hutopy.Infrastructure.Security;
using JetBrains.Annotations; using Hutopy.Modules.Creators.Data;
using Microsoft.EntityFrameworkCore;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Modules.Creators.Features;
[PublicAPI] [PublicAPI]
public record ChangeEmailRequest( public record ChangeEmailRequest(
@@ -28,7 +25,7 @@ public sealed class ChangeEmailRequestValidator : Validator<ChangeEmailRequest>
[PublicAPI] [PublicAPI]
public class ChangeEmailHandler( public class ChangeEmailHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<ChangeEmailRequest> : Endpoint<ChangeEmailRequest>
{ {
public override void Configure() public override void Configure()
@@ -67,4 +64,4 @@ public class ChangeEmailHandler(
await SendOkAsync(ct); await SendOkAsync(ct);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security; using Hutopy.Infrastructure.Security;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Modules.Creators.Features;
[PublicAPI] [PublicAPI]
public record ChangeSlugRequest( public record ChangeSlugRequest(
@@ -26,7 +26,7 @@ internal sealed class ChangeSlugRequestValidator
[PublicAPI] [PublicAPI]
public class ChangeSlugHandler( public class ChangeSlugHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<ChangeSlugRequest> : Endpoint<ChangeSlugRequest>
{ {
public override void Configure() 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] [PublicAPI]
public record ChangeSocialsRequest( public record ChangeSocialsRequest(
@@ -16,7 +16,7 @@ public record ChangeSocialsRequest(
[PublicAPI] [PublicAPI]
public class ChangeSocialsHandler( public class ChangeSocialsHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<ChangeSocialsRequest> : Endpoint<ChangeSocialsRequest>
{ {
public override void Configure() 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] [PublicAPI]
public record ChangeTitleRequest( public record ChangeTitleRequest(
@@ -9,7 +9,7 @@ public record ChangeTitleRequest(
[PublicAPI] [PublicAPI]
public class ChangeTitleHandler( public class ChangeTitleHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<ChangeTitleRequest> : Endpoint<ChangeTitleRequest>
{ {
public override void Configure() 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.Infrastructure.Security;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Modules.Creators.Features;
[PublicAPI] [PublicAPI]
public record CreateCreatorRequest( public record CreateCreatorRequest(
@@ -27,7 +27,7 @@ public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorReque
[PublicAPI] [PublicAPI]
public sealed class CreateCreatorHandler( public sealed class CreateCreatorHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<CreateCreatorRequest> : Endpoint<CreateCreatorRequest>
{ {
public override void Configure() public override void Configure()
@@ -74,7 +74,7 @@ public sealed class CreateCreatorHandler(
await SendOkAsync(ct); await SendOkAsync(ct);
} }
catch (Exception e) catch (Exception)
{ {
await transaction.RollbackAsync(ct); 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] [PublicAPI]
public sealed class GetCreatorByIdRequest public sealed class GetCreatorByIdRequest
@@ -22,7 +22,7 @@ public sealed class GetCreatorByIdRequestValidator
[PublicAPI] [PublicAPI]
public class GetCreatorByIdHandler( public class GetCreatorByIdHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<GetCreatorByIdRequest, Creator> : Endpoint<GetCreatorByIdRequest, Creator>
{ {
public override void Configure() public override void Configure()

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using Hutopy.Web.Common.Security; using Hutopy.Infrastructure.Security;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Modules.Creators.Data;
namespace Hutopy.Web.Features.Contents.Handlers; namespace Hutopy.Modules.Creators.Features;
[PublicAPI] [PublicAPI]
public record RestoreCreatorRequest( public record RestoreCreatorRequest(
@@ -21,7 +21,7 @@ public sealed class RestoreCreatorRequestValidator : Validator<RestoreCreatorReq
[PublicAPI] [PublicAPI]
public sealed class RestoreCreatorHandler( public sealed class RestoreCreatorHandler(
ContentDbContext context) CreatorsDbContext context)
: Endpoint<RestoreCreatorRequest> : Endpoint<RestoreCreatorRequest>
{ {
public override void Configure() 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 /> // <auto-generated />
using System; using System;
using Hutopy.Web.Features.Contents.Data; using Hutopy.Modules.Creators.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -9,75 +9,24 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations namespace Hutopy.Modules.Creators.Migrations
{ {
[DbContext(typeof(ContentDbContext))] [DbContext(typeof(CreatorsDbContext))]
[Migration("20250423153323_AddPresentation")] [Migration("20250609203815_Initial")]
partial class AddPresentation partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("Content") .HasDefaultSchema("Creators")
.HasAnnotation("ProductVersion", "9.0.3") .HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", 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 =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -107,10 +56,19 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); .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") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(255) .HasMaxLength(256)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl") b.Property<string>("PortraitUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
@@ -121,19 +79,23 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<string>("StripeAccountId")
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.Property<string>("Title") b.Property<string>("Title")
.HasMaxLength(255) .HasMaxLength(256)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(256)");
b.Property<bool>("Verified") b.Property<bool>("Verified")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.HasKey("Id"); 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") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -155,7 +117,7 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasComputedColumnSql("LOWER( \"Content\".\"Slugs\".\"Name\")", true); .HasComputedColumnSql("LOWER(\"Name\")", true);
b.Property<DateTimeOffset>("ReservedUntil") b.Property<DateTimeOffset>("ReservedUntil")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -168,69 +130,27 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b.HasIndex("NormalizedName") b.HasIndex("NormalizedName")
.IsUnique(); .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") b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
.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 =>
{ {
b1.Property<Guid>("CreatorId") b1.Property<Guid>("CreatorId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("Description") b1.Property<string>("Description")
.IsRequired() .IsRequired()
.HasMaxLength(2000) .HasColumnType("text");
.HasColumnType("character varying(2000)");
b1.Property<string>("Email") b1.Property<string>("Email")
.HasMaxLength(255) .HasMaxLength(256)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(256)");
b1.Property<string>("PhoneNumber") b1.Property<string>("PhoneNumber")
.HasMaxLength(255) .HasMaxLength(256)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(256)");
b1.Property<string>("VideoUrl") b1.Property<string>("VideoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
@@ -238,13 +158,13 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b1.HasKey("CreatorId"); b1.HasKey("CreatorId");
b1.ToTable("Presentation", "Content"); b1.ToTable("Presentation", "Creators");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CreatorId"); .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") b1.Property<Guid>("CreatorId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -283,7 +203,7 @@ namespace Hutopy.Web.Features.Contents.Data.Migrations
b1.HasKey("CreatorId"); b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content"); b1.ToTable("Socials", "Creators");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CreatorId"); .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 #nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations namespace Hutopy.Modules.Creators.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class Adds_AcceptDonation_Creator : Migration public partial class AddStripe : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<bool>( migrationBuilder.DropColumn(
name: "AcceptDonation", name: "AcceptDonation",
schema: "Content", schema: "Creators",
table: "Creators");
migrationBuilder.RenameColumn(
name: "IsStripeOnboardingComplete",
schema: "Creators",
table: "Creators", table: "Creators",
type: "boolean", newName: "IsStripeDetailsSubmitted");
nullable: false,
defaultValue: false);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.RenameColumn(
name: "IsStripeDetailsSubmitted",
schema: "Creators",
table: "Creators",
newName: "IsStripeOnboardingComplete");
migrationBuilder.AddColumn<bool>(
name: "AcceptDonation", name: "AcceptDonation",
schema: "Content", schema: "Creators",
table: "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 readonly SemaphoreSlim Semaphore = new(1, 1);
private static DateTimeOffset _lastPurgeTime = DateTimeOffset.MinValue; private static DateTimeOffset s_lastPurgeTime = DateTimeOffset.MinValue;
private static readonly TimeSpan _minTimeBetweenPurges = TimeSpan.FromSeconds(10); private static readonly TimeSpan MinTimeBetweenPurges = TimeSpan.FromSeconds(10);
public async Task PurgeExpiredSlugsAsync(CancellationToken ct) public async Task PurgeExpiredSlugsAsync(CancellationToken ct)
{ {
// Try to acquire the semaphore // 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 // Another purge operation is in progress, skip this one
return; return;
@@ -18,7 +20,7 @@ public class SlugPurger(ContentDbContext context)
try try
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
if (now - _lastPurgeTime < _minTimeBetweenPurges) if (now - s_lastPurgeTime < MinTimeBetweenPurges)
{ {
// Not enough time has passed since the last purge // Not enough time has passed since the last purge
return; return;
@@ -31,11 +33,11 @@ public class SlugPurger(ContentDbContext context)
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
// Update the last purge time regardless of whether we found expired slugs or not // Update the last purge time regardless of whether we found expired slugs or not
_lastPurgeTime = now; s_lastPurgeTime = now;
} }
finally finally
{ {
_semaphore.Release(); Semaphore.Release();
} }
} }
} }

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Users; namespace Hutopy.Modules.Identity.Configuration;
public record JwtOptions 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 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; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
namespace Hutopy.Web.Features.Users.Data namespace Hutopy.Modules.Identity.Data
{ {
public class ApplicationDbContext( public class IdentityDbContext(
DbContextOptions<ApplicationDbContext> options) DbContextOptions<IdentityDbContext> options)
: IdentityDbContext<IdentityUser, IdentityRole, Guid>(options) : IdentityDbContext<User, Role, Guid>(options)
{ {
public const string SchemaName = "Identity"; public const string SchemaName = "Identity";
protected override void OnModelCreating(ModelBuilder protected override void OnModelCreating(ModelBuilder
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.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Hutopy.Web.Features.Users.Data; namespace Hutopy.Modules.Identity.Data;
public sealed class IdentityUserManager( public sealed class UserManager(
IUserStore<IdentityUser> store, IUserStore<User> store,
IOptions<IdentityOptions> optionsAccessor, IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<IdentityUser> passwordHasher, IPasswordHasher<User> passwordHasher,
IEnumerable<IUserValidator<IdentityUser>> userValidators, IEnumerable<IUserValidator<User>> userValidators,
IEnumerable<IPasswordValidator<IdentityUser>> passwordValidators, IEnumerable<IPasswordValidator<User>> passwordValidators,
ILookupNormalizer keyNormalizer, ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors, IdentityErrorDescriber errors,
IServiceProvider services, IServiceProvider services,
ILogger<UserManager<IdentityUser>> logger) ILogger<UserManager<User>> logger)
: UserManager<IdentityUser>( : UserManager<User>(
store, store,
optionsAccessor, optionsAccessor,
passwordHasher, 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.Infrastructure.Security;
using Hutopy.Web.Features.Users.Data; using Hutopy.Modules.Identity.Data;
namespace Hutopy.Web.Features.Users.Handlers; namespace Hutopy.Modules.Identity.Handlers;
[PublicAPI] [PublicAPI]
public record ChangeAddressRequest( public record ChangeAddressRequest(
@@ -9,7 +9,7 @@ public record ChangeAddressRequest(
[PublicAPI] [PublicAPI]
public class ChangeAddressHandler( public class ChangeAddressHandler(
IdentityUserManager userManager) UserManager userManager)
: Endpoint<ChangeAddressRequest> : Endpoint<ChangeAddressRequest>
{ {
public override void Configure() public override void Configure()

View File

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

View File

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

View File

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

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