many fixes and improvements - rework for modules/ and common/
feat(emailer): add Postmark and Resend providers
This commit is contained in:
6
backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs
Normal file
6
backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Hutopy.Modules.Tipping.Contracts;
|
||||
|
||||
public interface ITipPaymentNotifier
|
||||
{
|
||||
Task NotifyPaymentSucceedAsync(string stripeId, string invoiceUrl, CancellationToken ct);
|
||||
}
|
||||
16
backend/Modules/Tipping/Contracts/ITipProcessor.cs
Normal file
16
backend/Modules/Tipping/Contracts/ITipProcessor.cs
Normal 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);
|
||||
}
|
||||
5
backend/Modules/Tipping/Contracts/TipCheckoutSession.cs
Normal file
5
backend/Modules/Tipping/Contracts/TipCheckoutSession.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Hutopy.Modules.Tipping.Contracts;
|
||||
|
||||
public record TipCheckoutSession(
|
||||
string Id,
|
||||
string Url);
|
||||
21
backend/Modules/Tipping/Data/Tip.cs
Normal file
21
backend/Modules/Tipping/Data/Tip.cs
Normal 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,
|
||||
}
|
||||
22
backend/Modules/Tipping/Data/TippingDbContext.cs
Normal file
22
backend/Modules/Tipping/Data/TippingDbContext.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
31
backend/Modules/Tipping/DependencyInjection.cs
Normal file
31
backend/Modules/Tipping/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
backend/Modules/Tipping/Handlers/GetReceivedTips.cs
Normal file
49
backend/Modules/Tipping/Handlers/GetReceivedTips.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
116
backend/Modules/Tipping/Handlers/SendTip.cs
Normal file
116
backend/Modules/Tipping/Handlers/SendTip.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
80
backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs
generated
Normal file
80
backend/Modules/Tipping/Migrations/20250609171342_Initial.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/Modules/Tipping/Migrations/20250609171342_Initial.cs
Normal file
49
backend/Modules/Tipping/Migrations/20250609171342_Initial.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/Modules/Tipping/Models/TipReceivedModel.cs
Normal file
11
backend/Modules/Tipping/Models/TipReceivedModel.cs
Normal 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);
|
||||
31
backend/Modules/Tipping/Services/TipPaymentNotifier.cs
Normal file
31
backend/Modules/Tipping/Services/TipPaymentNotifier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user