From ed3159c05dfa8e889c213e2e3bc8fb982e35b462 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 3 Oct 2024 19:27:26 +0200 Subject: [PATCH] feat(backend): add support conversations --- .../Moderation/SupportController.cs | 250 ++++++++++++++++++ Foxnouns.Backend/Database/DatabaseContext.cs | 6 + .../20241003150352_AddSupportConversations.cs | 118 +++++++++ .../DatabaseContextModelSnapshot.cs | 119 +++++++++ .../Database/Models/SupportConversation.cs | 19 ++ .../Database/Models/SupportMessage.cs | 30 +++ Foxnouns.Backend/ExpectedError.cs | 6 + .../Extensions/WebApplicationExtensions.cs | 1 + .../Services/SupportRendererService.cs | 47 ++++ .../Services/UserRendererService.cs | 3 + Foxnouns.Backend/Utils/AuthUtils.cs | 2 + Foxnouns.Backend/Utils/ValidationUtils.cs | 19 ++ 12 files changed, 620 insertions(+) create mode 100644 Foxnouns.Backend/Controllers/Moderation/SupportController.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20241003150352_AddSupportConversations.cs create mode 100644 Foxnouns.Backend/Database/Models/SupportConversation.cs create mode 100644 Foxnouns.Backend/Database/Models/SupportMessage.cs create mode 100644 Foxnouns.Backend/Services/SupportRendererService.cs diff --git a/Foxnouns.Backend/Controllers/Moderation/SupportController.cs b/Foxnouns.Backend/Controllers/Moderation/SupportController.cs new file mode 100644 index 0000000..c03e2f6 --- /dev/null +++ b/Foxnouns.Backend/Controllers/Moderation/SupportController.cs @@ -0,0 +1,250 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace Foxnouns.Backend.Controllers.Moderation; + +[Route("/api/v2/support")] +public class SupportController( + ILogger logger, + DatabaseContext db, + SupportRendererService supportRenderer, + ISnowflakeGenerator snowflakeGenerator +) : ApiControllerBase +{ + private readonly ILogger _logger = logger.ForContext(); + + [HttpGet] + [ProducesResponseType>( + statusCode: StatusCodes.Status200OK + )] + [Authorize(":moderator")] + public async Task GetOpenConversationsAsync(CancellationToken ct = default) + { + var conversations = await db + .SupportConversations.Where(c => c.Status == SupportConversationStatus.Open) + .OrderBy(c => c.Id) + .ToListAsync(ct); + + return Ok(conversations.Select(supportRenderer.ToResponse)); + } + + [HttpGet("{id}")] + [ProducesResponseType( + statusCode: StatusCodes.Status200OK + )] + [Authorize("user.support")] + public async Task GetConversationAsync( + Snowflake id, + CancellationToken ct = default + ) + { + var (_, conversation) = await ResolveConversationAsync(id, ct); + + return Ok(supportRenderer.ToResponse(conversation)); + } + + [HttpGet("{id}/messages")] + [ProducesResponseType( + statusCode: StatusCodes.Status200OK + )] + [Authorize("user.support")] + public async Task GetConversationMessagesAsync( + Snowflake id, + CancellationToken ct = default + ) + { + var (isModerator, conversation) = await ResolveConversationAsync(id, ct); + + var messages = await db + .SupportMessages.Where(m => m.ConversationId == conversation.Id) + .OrderBy(m => m.Id) + .ToListAsync(ct); + + return Ok(messages.Select(m => supportRenderer.ToResponse(m, modView: isModerator))); + } + + private const int MaxOpenConversations = 5; + private const int MinTitleLength = 5; + private const int MaxTitleLength = 512; + private const int MaxMessageLength = 4096; + + [HttpPost] + [ProducesResponseType( + statusCode: StatusCodes.Status200OK + )] + [Authorize("user.support")] + public async Task CreateConversationAsync( + [FromBody] CreateConversationRequest req + ) + { + var openCount = await db + .SupportConversations.Where(c => + c.UserId == CurrentUser!.Id && c.Status == SupportConversationStatus.Open + ) + .CountAsync(); + if (openCount >= MaxOpenConversations) + throw new ApiError.BadRequest("Too many open conversations."); + + req.Title.ValidateStringLength("title", MinTitleLength, MaxTitleLength); + req.InitialMessage.Content?.ValidateStringLength( + "initial_message.content", + 0, + MaxMessageLength + ); + + var conversation = new SupportConversation + { + Id = snowflakeGenerator.GenerateSnowflake(), + User = CurrentUser!, + Title = req.Title, + }; + + var message = new SupportMessage + { + Id = snowflakeGenerator.GenerateSnowflake(), + ConversationId = conversation.Id, + UserId = CurrentUser!.Id, + IsAnonymous = false, + Content = req.InitialMessage.Content, + }; + + db.Add(conversation); + db.Add(message); + + await db.SaveChangesAsync(); + + return Ok(supportRenderer.ToResponse(conversation)); + } + + [HttpPost("{id}/messages")] + public async Task CreateMessageAsync( + Snowflake id, + [FromBody] CreateMessageRequest req + ) + { + var (isModerator, conversation) = await ResolveConversationAsync(id); + + req.Content?.ValidateStringLength("content", 0, MaxMessageLength); + if (req.IsAnonymous && !isModerator) + { + throw new ApiError.BadRequest( + "Only moderators can mark a message as anonymous.", + "is_anonymous", + true + ); + } + + var message = new SupportMessage + { + Id = snowflakeGenerator.GenerateSnowflake(), + ConversationId = conversation.Id, + UserId = CurrentUser!.Id, + IsAnonymous = req.IsAnonymous, + Content = req.Content, + }; + + db.Add(message); + await db.SaveChangesAsync(); + + return Ok(supportRenderer.ToResponse(message, modView: isModerator)); + } + + private async Task<( + bool IsModerator, + SupportConversation Conversation + )> ResolveConversationAsync(Snowflake id, CancellationToken ct = default) + { + var isModerator = CurrentUser!.Role is UserRole.Moderator or UserRole.Admin; + + var conversation = await db.SupportConversations.FindAsync([id], ct); + if (conversation == null) + throw new ApiError.NotFound("Conversation not found"); + if (!isModerator && conversation.UserId != CurrentUser.Id) + throw new ApiError.NotFound("Conversation not found"); + + return (isModerator, conversation); + } + + [HttpPatch("{id}")] + [ProducesResponseType( + statusCode: StatusCodes.Status200OK + )] + [Authorize("user.support")] + public async Task UpdateConversationAsync( + Snowflake id, + [FromBody] UpdateConversationRequest req + ) + { + var isModerator = CurrentUser!.Role is UserRole.Moderator or UserRole.Admin; + + var conversation = await db.SupportConversations.FindAsync(id); + if (conversation == null) + throw new ApiError.NotFound("Conversation not found"); + if (!isModerator && conversation.UserId != CurrentUser.Id) + throw new ApiError.NotFound("Conversation not found"); + + if ( + !isModerator + && ( + req.AssignedTo != null + || (req.Status != null && req.Status != SupportConversationStatus.Closed) + ) + ) + { + throw new ApiError.Forbidden("You can't change those options."); + } + + if (req.Title != null) + { + req.Title.ValidateStringLength("title", MinTitleLength, MaxTitleLength); + conversation.Title = req.Title; + } + + if (req.HasProperty(nameof(req.AssignedTo))) + { + if (req.AssignedTo == null) + conversation.AssignedToId = null; + else + { + var assignedToUser = await db.ResolveUserAsync(req.AssignedTo.Value); + if (assignedToUser.Role is not (UserRole.Admin or UserRole.Moderator)) + { + throw new ApiError.BadRequest( + "That user is not a moderator.", + "assigned_to", + req.AssignedTo + ); + } + + conversation.AssignedToId = assignedToUser.Id; + } + } + + if (req.Status != null) + conversation.Status = req.Status.Value; + + db.Update(conversation); + await db.SaveChangesAsync(); + + return Ok(supportRenderer.ToResponse(conversation)); + } + + public record CreateConversationRequest(string Title, CreateMessageRequest InitialMessage); + + public record CreateMessageRequest(string? Content, bool IsAnonymous = false); + + public class UpdateConversationRequest : PatchRequest + { + public string? Title { get; init; } + public Snowflake? AssignedTo { get; init; } + + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] + public SupportConversationStatus? Status { get; init; } + } +} diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 95e0317..ce2a423 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -26,6 +26,9 @@ public class DatabaseContext : DbContext public DbSet UserFlags { get; set; } public DbSet MemberFlags { get; set; } + public DbSet SupportConversations { get; set; } + public DbSet SupportMessages { get; set; } + public DatabaseContext(Config config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) @@ -88,6 +91,9 @@ public class DatabaseContext : DbContext modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); + modelBuilder.Entity().Property(m => m.Attachments).HasColumnType("jsonb"); + modelBuilder.Entity().Property(m => m.History).HasColumnType("jsonb"); + modelBuilder .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) .HasName("find_free_user_sid"); diff --git a/Foxnouns.Backend/Database/Migrations/20241003150352_AddSupportConversations.cs b/Foxnouns.Backend/Database/Migrations/20241003150352_AddSupportConversations.cs new file mode 100644 index 0000000..ee903a2 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241003150352_AddSupportConversations.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241003150352_AddSupportConversations")] + public partial class AddSupportConversations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "support_conversations", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + title = table.Column(type: "text", nullable: false), + assigned_to_id = table.Column(type: "bigint", nullable: true), + status = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("pk_support_conversations", x => x.id); + table.ForeignKey( + name: "fk_support_conversations_users_assigned_to_id", + column: x => x.assigned_to_id, + principalTable: "users", + principalColumn: "id" + ); + table.ForeignKey( + name: "fk_support_conversations_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "support_messages", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + conversation_id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + is_anonymous = table.Column(type: "boolean", nullable: false), + content = table.Column(type: "text", nullable: true), + attachments = table.Column>( + type: "jsonb", + nullable: false + ), + history = table.Column>( + type: "jsonb", + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("pk_support_messages", x => x.id); + table.ForeignKey( + name: "fk_support_messages_support_conversations_conversation_id", + column: x => x.conversation_id, + principalTable: "support_conversations", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_support_messages_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_support_conversations_assigned_to_id", + table: "support_conversations", + column: "assigned_to_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_support_conversations_user_id", + table: "support_conversations", + column: "user_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_support_messages_conversation_id", + table: "support_messages", + column: "conversation_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_support_messages_user_id", + table: "support_messages", + column: "user_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "support_messages"); + + migrationBuilder.DropTable(name: "support_conversations"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index e1e05c2..8809a15 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -266,6 +266,85 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("pride_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.SupportConversation", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AssignedToId") + .HasColumnType("bigint") + .HasColumnName("assigned_to_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_support_conversations"); + + b.HasIndex("AssignedToId") + .HasDatabaseName("ix_support_conversations_assigned_to_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_support_conversations_user_id"); + + b.ToTable("support_conversations", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.SupportMessage", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property>("Attachments") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("attachments"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ConversationId") + .HasColumnType("bigint") + .HasColumnName("conversation_id"); + + b.Property>("History") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("history"); + + b.Property("IsAnonymous") + .HasColumnType("boolean") + .HasColumnName("is_anonymous"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_support_messages"); + + b.HasIndex("ConversationId") + .HasDatabaseName("ix_support_messages_conversation_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_support_messages_user_id"); + + b.ToTable("support_messages", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") @@ -542,6 +621,46 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_pride_flags_users_user_id"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.SupportConversation", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "AssignedTo") + .WithMany() + .HasForeignKey("AssignedToId") + .HasConstraintName("fk_support_conversations_users_assigned_to_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_support_conversations_users_user_id"); + + b.Navigation("AssignedTo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.SupportMessage", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.SupportConversation", "Conversation") + .WithMany() + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_support_messages_support_conversations_conversation_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_support_messages_users_user_id"); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") diff --git a/Foxnouns.Backend/Database/Models/SupportConversation.cs b/Foxnouns.Backend/Database/Models/SupportConversation.cs new file mode 100644 index 0000000..b9929bb --- /dev/null +++ b/Foxnouns.Backend/Database/Models/SupportConversation.cs @@ -0,0 +1,19 @@ +namespace Foxnouns.Backend.Database.Models; + +public class SupportConversation : BaseModel +{ + public Snowflake UserId { get; init; } + public User User { get; init; } = null!; + public required string Title { get; set; } + public Snowflake? AssignedToId { get; set; } + public User? AssignedTo { get; set; } + + public SupportConversationStatus Status { get; set; } = SupportConversationStatus.Open; +} + +public enum SupportConversationStatus +{ + Open, + Resolved, + Closed, +} diff --git a/Foxnouns.Backend/Database/Models/SupportMessage.cs b/Foxnouns.Backend/Database/Models/SupportMessage.cs new file mode 100644 index 0000000..82ba2bd --- /dev/null +++ b/Foxnouns.Backend/Database/Models/SupportMessage.cs @@ -0,0 +1,30 @@ +using NodaTime; + +namespace Foxnouns.Backend.Database.Models; + +public class SupportMessage : BaseModel +{ + public Snowflake ConversationId { get; init; } + public SupportConversation Conversation { get; init; } = null!; + public Snowflake UserId { get; init; } + public User User { get; init; } = null!; + public bool IsAnonymous { get; init; } + + public string? Content { get; set; } + + public List Attachments { get; set; } = []; + public List History { get; set; } = []; + + public class Attachment + { + public required Snowflake Id { get; init; } + public required string Hash { get; init; } + public required string ContentType { get; init; } + } + + public class HistoryEntry + { + public required Instant Timestamp { get; init; } + public required string? Content { get; init; } + } +} diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index fdd0b5d..3e02516 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -51,6 +51,12 @@ public class ApiError( } ) { } + public BadRequest(string field, ValidationError error) + : this( + "Error validating input", + new Dictionary> { { field, [error] } } + ) { } + public JObject ToJson() { var o = new JObject diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 3e6926c..a6d2a97 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -98,6 +98,7 @@ public static class WebApplicationExtensions .AddSingleton() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Foxnouns.Backend/Services/SupportRendererService.cs b/Foxnouns.Backend/Services/SupportRendererService.cs new file mode 100644 index 0000000..77ae0f1 --- /dev/null +++ b/Foxnouns.Backend/Services/SupportRendererService.cs @@ -0,0 +1,47 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; +using Newtonsoft.Json; + +namespace Foxnouns.Backend.Services; + +public class SupportRendererService(UserRendererService userRenderer) +{ + public ConversationResponse ToResponse(SupportConversation c) => + new( + c.Id, + userRenderer.RenderPartialUser(c.User), + c.Title, + userRenderer.TryRenderPartialUser(c.AssignedTo), + c.Status + ); + + public MessageResponse ToResponse(SupportMessage m, bool modView = false) => + new( + m.Id, + m.IsAnonymous + ? modView + ? userRenderer.RenderPartialUser(m.User) + : null + : userRenderer.RenderPartialUser(m.User), + m.IsAnonymous, + m.Content + ); + + public record ConversationResponse( + Snowflake Id, + UserRendererService.PartialUser User, + string Title, + UserRendererService.PartialUser? AssignedTo, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] + SupportConversationStatus Status + ); + + public record MessageResponse( + Snowflake Id, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + UserRendererService.PartialUser? User, + bool IsAnonymous, + string? Content + ); +} diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index b47832d..aef0d6c 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -92,6 +92,9 @@ public class UserRendererService( user.CustomPreferences ); + public PartialUser? TryRenderPartialUser(User? user) => + user != null ? RenderPartialUser(user) : null; + private string? AvatarUrlFor(User user) => user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index aaf0c08..b477855 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -7,6 +7,7 @@ public static class AuthUtils { public const string ClientCredentials = "client_credentials"; public const string AuthorizationCode = "authorization_code"; + private static readonly string[] ForbiddenSchemes = [ "javascript", @@ -21,6 +22,7 @@ public static class AuthUtils "user.read_hidden", "user.read_privileged", "user.update", + "user.support", ]; public static readonly string[] MemberScopes = diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index 0969a47..855d3d2 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -9,6 +9,25 @@ namespace Foxnouns.Backend.Utils; /// public static partial class ValidationUtils { + public static void ValidateStringLength( + this string s, + string fieldName, + int minLength, + int maxLength + ) + { + if (s.Length < minLength) + throw new ApiError.BadRequest( + fieldName, + ValidationError.LengthError("Field is too short", minLength, maxLength, s.Length) + ); + if (s.Length > maxLength) + throw new ApiError.BadRequest( + fieldName, + ValidationError.LengthError("Field is too long", minLength, maxLength, s.Length) + ); + } + private static readonly string[] InvalidUsernames = [ "..",