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; } } }