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,11 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Common.Domain;
namespace Hutopy.Modules.Messaging.Data;
public class Message : Entity
{
public Guid SubjectId { get; set; }
public Guid? ParentId { get; set; }
[MaxLength(2048)] public required string Value { get; set; }
}

View File

@@ -0,0 +1,93 @@
using Hutopy.Modules.Identity.Contracts;
using Hutopy.Modules.Messaging.Models;
namespace Hutopy.Modules.Messaging.Data;
public class MessagingDbContext(
IUserLookup userLookup,
DbContextOptions<MessagingDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Messaging";
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Message>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
public DbSet<Message> Messages { get; set; }
public async Task<IEnumerable<MessageDto>> GetMessagesAsync(
Guid subjectId,
Guid? parentId,
Guid? lastId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
if (lastId.HasValue)
{
var lastMessage = await Messages
.Where(c => c.Id == lastId.Value)
.Select(c => new { c.CreatedAt, c.Id })
.FirstOrDefaultAsync(cancellationToken: ct);
if (lastMessage != null)
{
query = query
.Where(c => c.CreatedAt < lastMessage.CreatedAt
|| (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id));
}
}
var messages = await query
.OrderByDescending(c => c.CreatedAt)
.ThenByDescending(c => c.Id)
.Take(pageSize)
.ToListAsync(cancellationToken: ct);
var result = await Task.WhenAll(
messages.Select(async message =>
{
var writer = await userLookup.GetUserAsync(message.CreatedBy, ct);
return new MessageDto(
message.Id,
message.SubjectId,
message.CreatedBy,
writer?.Fullname ?? "Unknown User",
writer?.PortraitUrl,
message.CreatedAt,
message.ParentId,
message.Value);
}));
return result;
}
public async Task<int> GetMessageCountAsync(
Guid subjectId,
Guid? parentId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
var messageCount = await query
.Take(pageSize)
.CountAsync(ct);
return messageCount;
}
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMessagingModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<MessagingDbContext>(configureAction);
return builder;
}
public static async Task<IApplicationBuilder> UseMessagingModuleAsync(
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,55 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging.Handlers;
[PublicAPI]
public sealed class AddMessageRequest
{
public Guid? Id { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class AddMessageRequestValidator
: Validator<AddMessageRequest>
{
public AddMessageRequestValidator()
{
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
public class AddMessage(
MessagingDbContext context)
: Endpoint<AddMessageRequest>
{
public override void Configure()
{
Post("/api/messages");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
AddMessageRequest req,
CancellationToken ct)
{
var message = new Message
{
Id = req.Id ?? Guid.CreateVersion7(),
SubjectId = req.SubjectId,
CreatedBy = User.GetUserId(),
Value = req.Message
};
await context.Messages.AddAsync(message, ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,61 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging.Handlers;
[PublicAPI]
public sealed class AddReplyRequest
{
public Guid? Id { get; set; }
public required Guid ParentId { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class AddReplyRequestValidator
: Validator<AddReplyRequest>
{
public AddReplyRequestValidator()
{
RuleFor(r => r.ParentId)
.NotNull().WithMessage("You must specify a ParentId")
.NotEmpty().WithMessage("You must specify a non-empty ParentId");
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
internal sealed class AddReply(
MessagingDbContext context)
: Endpoint<AddReplyRequest>
{
public override void Configure()
{
Post("/api/messages/{ParentId:guid}/replies");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
AddReplyRequest req,
CancellationToken ct)
{
var message = new Message
{
Id = Guid.CreateVersion7(),
SubjectId = req.SubjectId,
ParentId = req.ParentId,
CreatedBy = User.GetUserId(),
Value = req.Message
};
await context.Messages.AddAsync(message, ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,64 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging.Handlers;
public sealed class ChangeMessageRequest
{
public Guid? Id { get; set; }
public required Guid SubjectId { get; set; }
public required string Message { get; set; }
}
internal sealed class ChangeMessageRequestValidator
: Validator<ChangeMessageRequest>
{
public ChangeMessageRequestValidator()
{
RuleFor(r => r.SubjectId)
.NotNull().WithMessage("You must specify a SubjectId")
.NotEmpty().WithMessage("You must specify a non-empty SubjectId");
RuleFor(r => r.Message)
.NotNull().WithMessage("You must specify a Message")
.NotEmpty().WithMessage("You must specify a non-empty Message");
}
}
public class ChangeMessage(
MessagingDbContext context)
: Endpoint<ChangeMessageRequest>
{
public override void Configure()
{
Post("/api/messages/update");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
ChangeMessageRequest req,
CancellationToken ct)
{
var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.Id, ct);
if (message is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (message.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
message.SubjectId = req.SubjectId;
message.Value = req.Message;
context.Update(message);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,52 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging.Handlers;
public record DeleteMessageRequest(Guid MessageId);
internal sealed class DeleteMessageRequestValidator
: Validator<DeleteMessageRequest>
{
public DeleteMessageRequestValidator()
{
RuleFor(r => r.MessageId)
.NotNull().WithMessage("You must specify a MessageId")
.NotEmpty().WithMessage("You must specify a non-empty MessageId");
}
}
public class DeleteMessage(
MessagingDbContext context)
: Endpoint<DeleteMessageRequest>
{
public override void Configure()
{
Delete("/api/messages/{MessageId}");
Options(o => o.WithTags("Messages"));
}
public override async Task HandleAsync(
DeleteMessageRequest req,
CancellationToken ct)
{
var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.MessageId, ct);
if (message is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (message.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
context.Messages.Remove(message);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,44 @@
using Hutopy.Modules.Messaging.Data;
namespace Hutopy.Modules.Messaging.Handlers;
public sealed class GetMessageCountRequest
{
public Guid SubjectId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 1000;
}
public record struct GetMessageCountResponse
{
public required int Count { get; init; }
}
public class GetMessageCount(
MessagingDbContext context)
: Endpoint<GetMessageCountRequest, GetMessageCountResponse>
{
public override void Configure()
{
Get("/api/messages/{SubjectId:guid}/count");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessageCountRequest req,
CancellationToken ct)
{
var messageCount = await context.GetMessageCountAsync(
req.SubjectId,
null,
req.PageSize,
ct);
await SendAsync(
new()
{
Count = messageCount
},
cancellation: ct);
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Modules.Messaging.Data;
using Hutopy.Modules.Messaging.Models;
namespace Hutopy.Modules.Messaging.Handlers;
[PublicAPI]
public sealed class GetMessagesRequest
{
public Guid SubjectId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public record struct GetMessagesResponse(
IEnumerable<MessageDto> Messages);
public class GetMessages(
MessagingDbContext context)
: Endpoint<GetMessagesRequest, GetMessagesResponse>
{
public override void Configure()
{
Get("/api/messages/{SubjectId:guid}");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessagesRequest req,
CancellationToken ct)
{
var messages = await context.GetMessagesAsync(
req.SubjectId,
null,
req.LastId,
req.PageSize,
ct);
await SendOkAsync(new GetMessagesResponse(messages), ct);
}
}

View File

@@ -0,0 +1,59 @@
using Hutopy.Modules.Identity.Contracts;
using Hutopy.Modules.Messaging.Data;
using Hutopy.Modules.Messaging.Models;
namespace Hutopy.Modules.Messaging.Handlers;
[PublicAPI]
public class GetMessagesByUserRequest
{
public Guid UserId { get; set; }
}
[PublicAPI]
public record struct GetMessagesByUserResponse(
IEnumerable<MessageDto> Messages);
public class GetMessagesByUser(
IUserLookup userLookup,
MessagingDbContext context)
: Endpoint<GetMessagesByUserRequest, GetMessagesByUserResponse>
{
public override void Configure()
{
Get("/api/messages/user/{UserId:guid}");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMessagesByUserRequest req,
CancellationToken ct)
{
var messages = await context
.Messages
.Where(c => c.CreatedBy == req.UserId)
.Where(c => c.ParentId == null)
.ToListAsync(cancellationToken: ct);
var result = await Task.WhenAll(
messages.Select(async message =>
{
var user = await userLookup.GetUserAsync(message.CreatedBy, ct);
return new MessageDto
{
Id = message.Id,
ParentId = message.ParentId,
CreatedAt = message.CreatedAt,
CreatedBy = message.CreatedBy,
CreatedByName = user?.Fullname ?? "Unknown User",
CreatedByPortraitUrl = user?.PortraitUrl ?? "",
SubjectId = message.SubjectId,
Value = message.Value
};
}));
await SendOkAsync(new GetMessagesByUserResponse(result), ct);
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Modules.Messaging.Data;
using Hutopy.Modules.Messaging.Models;
namespace Hutopy.Modules.Messaging.Handlers;
[PublicAPI]
public class GetRepliesRequest
{
public Guid SubjectId { get; set; }
public Guid ParentId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public record struct GetRepliesResponse(
IEnumerable<MessageDto> Messages);
public class GetReplies(
MessagingDbContext context)
: Endpoint<GetRepliesRequest, GetRepliesResponse>
{
public override void Configure()
{
Get("/api/messages/{ParentId:guid}/replies");
Options(o => o.WithTags("Messages"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetRepliesRequest req,
CancellationToken ct)
{
var replies = await context.GetMessagesAsync(
req.SubjectId,
req.ParentId,
req.LastId,
req.PageSize,
ct);
await SendOkAsync(new GetRepliesResponse(replies), ct);
}
}

View File

@@ -0,0 +1,67 @@
// <auto-generated />
using System;
using Hutopy.Modules.Messaging.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.Messaging.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20250609171331_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Messaging.Data.Message", 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<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Modules.Messaging.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Messaging");
migrationBuilder.CreateTable(
name: "Messages",
schema: "Messaging",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubjectId = table.Column<Guid>(type: "uuid", nullable: false),
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
Value = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages",
schema: "Messaging");
}
}
}

View File

@@ -0,0 +1,64 @@
// <auto-generated />
using System;
using Hutopy.Modules.Messaging.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Messaging.Migrations
{
[DbContext(typeof(MessagingDbContext))]
partial class MessagingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Messaging.Data.Message", 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<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Hutopy.Modules.Messaging.Models;
public record struct MessageDto(
Guid Id,
Guid SubjectId,
Guid CreatedBy,
string CreatedByName,
string? CreatedByPortraitUrl,
DateTimeOffset CreatedAt,
Guid? ParentId,
string Value
);