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

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

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipPaymentNotifier
{
Task NotifyPaymentSucceedAsync(string stripeId, string invoiceUrl, CancellationToken ct);
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Modules.Creators.Contracts;
namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipProcessor
{
Task<TipCheckoutSession> CreateCheckoutSessionAsync(
Guid tipId,
CreatorReference creator,
decimal amount,
string currency,
string message,
string successUrl,
string cancelUrl,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Modules.Tipping.Contracts;
public record TipCheckoutSession(
string Id,
string Url);

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Common.Domain;
namespace Hutopy.Modules.Tipping.Data;
public class Tip : Entity
{
public Guid CreatorId { get; set; }
public TipStatus Status { get; set; }
public decimal Amount { get; set; }
[MaxLength(8)] public required string Currency { get; set; }
[MaxLength(2048)] public required string Message { get; set; }
[MaxLength(256)] public required string StripeSessionId { get; set; }
[MaxLength(2048)] public string? StripeInvoiceUrl { get; set; }
}
public enum TipStatus : short
{
Pending = 0,
Paid = 1,
}

View File

@@ -0,0 +1,22 @@
namespace Hutopy.Modules.Tipping.Data;
public sealed class TippingDbContext(
DbContextOptions<TippingDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Tipping";
public DbSet<Tip> Tips => Set<Tip>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Tip>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}

View File

@@ -0,0 +1,31 @@
using Hutopy.Modules.Messaging.Data;
using Hutopy.Modules.Tipping.Contracts;
using Hutopy.Modules.Tipping.Data;
using Hutopy.Modules.Tipping.Services;
namespace Hutopy.Modules.Tipping;
public static class DependencyInjection
{
public static WebApplicationBuilder AddTippingModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<TippingDbContext>(configureAction);
builder.Services.AddTransient<ITipPaymentNotifier, TipPaymentNotifier>();
return builder;
}
public static async Task<IApplicationBuilder> UseTippingModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<MessagingDbContext>();
await context.Database.MigrateAsync(cancellationToken: cancellationToken);
return app;
}
}

View File

@@ -0,0 +1,49 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Contracts;
using Hutopy.Modules.Tipping.Data;
using Hutopy.Modules.Tipping.Models;
namespace Hutopy.Modules.Tipping.Handlers;
[PublicAPI]
public record struct GetReceivedTipsResponse(
IEnumerable<TipReceivedModel> Tips);
[PublicAPI]
public class GetReceivedTipsHandler(
IUserLookup userLookup,
TippingDbContext dbContext)
: EndpointWithoutRequest<GetReceivedTipsResponse>
{
public override void Configure()
{
Get("/api/tips");
Options(o => o.WithTags("Tips"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tips = await dbContext
.Tips
.Where(tip => tip.CreatorId == User.GetUserId())
.ToListAsync(ct);
var result = await Task.WhenAll(
tips.Select(async tip =>
{
var tipper = await userLookup.GetUserAsync(tip.CreatorId, ct);
return new TipReceivedModel(
tip.Id,
tip.CreatedAt,
tip.CreatedBy,
tipper?.Fullname ?? "Unknown User",
tip.Amount,
tip.Currency,
tip.Message);
}));
await SendOkAsync(new GetReceivedTipsResponse(result), ct);
}
}

View File

@@ -0,0 +1,116 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Tipping.Contracts;
using Hutopy.Modules.Tipping.Data;
namespace Hutopy.Modules.Tipping.Handlers;
[PublicAPI]
public record SendTipRequest(
Guid CreatorId,
decimal Amount,
string Currency,
string Message,
string CheckoutSuccessUrl,
string CheckoutCancelledUrl);
[PublicAPI]
public record SendTipResponse(
string Id,
string Url);
[PublicAPI]
public class SendTipRequestValidator : Validator<SendTipRequest>
{
public SendTipRequestValidator()
{
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Tip amount must be greater than 0");
RuleFor(x => x.CreatorId)
.NotEmpty()
.WithMessage("Creator ID is required");
RuleFor(x => x.CheckoutSuccessUrl)
.NotEmpty()
.WithMessage("CheckoutSuccessUrl is required");
RuleFor(x => x.CheckoutCancelledUrl)
.NotEmpty()
.WithMessage("CheckoutCancelledUrl is required");
}
}
[PublicAPI]
public class SendTipHandler(
TippingDbContext dbContext,
ITipProcessor tipProcessor,
ICreatorLookup creatorLookup)
: Endpoint<SendTipRequest, SendTipResponse>
{
private static readonly Guid AnonymousUserId = Guid.Parse("AAAAAAAA-0000-0000-0000-000000000000");
public override void Configure()
{
Post("/api/tips");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
SendTipRequest req,
CancellationToken ct)
{
CreatorReference? creator = await creatorLookup.GetCreatorAsync(req.CreatorId, ct);
if (creator == null)
{
await SendNotFoundAsync(ct);
return;
}
if (!creator.AcceptCharges)
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Guid tipId = Guid.CreateVersion7();
TipCheckoutSession checkout = await tipProcessor.CreateCheckoutSessionAsync(
tipId,
creator,
req.Amount,
req.Currency,
req.Message,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl,
ct);
Guid userId = User.Identity?.IsAuthenticated == true
? User.GetUserId()
: AnonymousUserId;
Tip tip = new()
{
Id = tipId,
CreatedAt = DateTimeOffset.UtcNow,
StripeSessionId = checkout.Id,
CreatedBy = userId,
CreatorId = req.CreatorId,
Status = TipStatus.Pending,
Amount = req.Amount,
Currency = req.Currency,
Message = req.Message
};
dbContext.Tips.Add(tip);
await dbContext.SaveChangesAsync(ct);
await SendAsync(
new SendTipResponse(checkout.Id, checkout.Url),
cancellation: ct);
}
}

View File

@@ -0,0 +1,80 @@
// <auto-generated />
using System;
using Hutopy.Modules.Tipping.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.Tipping.Migrations
{
[DbContext(typeof(TippingDbContext))]
[Migration("20250609171342_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Tipping")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Tipping.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
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<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<short>("Status")
.HasColumnType("smallint");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Tips", "Tipping");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Modules.Tipping.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Tipping");
migrationBuilder.CreateTable(
name: "Tips",
schema: "Tipping",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<short>(type: "smallint", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
StripeSessionId = table.Column<string>(type: "text", nullable: false),
StripeInvoiceUrl = table.Column<string>(type: "text", nullable: true),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tips", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tips",
schema: "Tipping");
}
}
}

View File

@@ -0,0 +1,77 @@
// <auto-generated />
using System;
using Hutopy.Modules.Tipping.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Tipping.Migrations
{
[DbContext(typeof(TippingDbContext))]
partial class TippingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Tipping")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Tipping.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
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<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<short>("Status")
.HasColumnType("smallint");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Tips", "Tipping");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Modules.Tipping.Models;
[PublicAPI]
public record struct TipReceivedModel(
Guid Id,
DateTimeOffset CreatedAt,
Guid TipperId,
string TipperName,
decimal Amount,
string Currency,
string Message);

View File

@@ -0,0 +1,31 @@
using Hutopy.Modules.Tipping.Contracts;
using Hutopy.Modules.Tipping.Data;
namespace Hutopy.Modules.Tipping.Services;
public class TipPaymentNotifier(
TippingDbContext dbContext,
ILogger<TipPaymentNotifier> logger)
: ITipPaymentNotifier
{
public async Task NotifyPaymentSucceedAsync(
string sessionId,
string invoiceUrl,
CancellationToken ct)
{
var tip = await dbContext.Tips.SingleOrDefaultAsync(
t => t.StripeSessionId == sessionId,
cancellationToken: ct);
if (tip is not null)
{
tip.Status = TipStatus.Paid;
tip.StripeInvoiceUrl = invoiceUrl;
await dbContext.SaveChangesAsync(ct);
}
else
{
logger.LogError("Tip with session ID {SessionId} not found", sessionId);
}
}
}