Foxnouns.NET/Foxnouns.Backend/Controllers/Moderation/SupportController.cs

250 lines
8 KiB
C#

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<SupportController>();
[HttpGet]
[ProducesResponseType<IEnumerable<SupportRendererService.ConversationResponse>>(
statusCode: StatusCodes.Status200OK
)]
[Authorize(":moderator")]
public async Task<IActionResult> 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<SupportRendererService.ConversationResponse>(
statusCode: StatusCodes.Status200OK
)]
[Authorize("user.support")]
public async Task<IActionResult> GetConversationAsync(
Snowflake id,
CancellationToken ct = default
)
{
var (_, conversation) = await ResolveConversationAsync(id, ct);
return Ok(supportRenderer.ToResponse(conversation));
}
[HttpGet("{id}/messages")]
[ProducesResponseType<SupportRendererService.ConversationResponse>(
statusCode: StatusCodes.Status200OK
)]
[Authorize("user.support")]
public async Task<IActionResult> 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<SupportRendererService.ConversationResponse>(
statusCode: StatusCodes.Status200OK
)]
[Authorize("user.support")]
public async Task<IActionResult> 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<IActionResult> 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<SupportRendererService.ConversationResponse>(
statusCode: StatusCodes.Status200OK
)]
[Authorize("user.support")]
public async Task<IActionResult> 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; }
}
}