feat(backend): add support conversations
This commit is contained in:
parent
a4ca0902a3
commit
ed3159c05d
12 changed files with 620 additions and 0 deletions
250
Foxnouns.Backend/Controllers/Moderation/SupportController.cs
Normal file
250
Foxnouns.Backend/Controllers/Moderation/SupportController.cs
Normal file
|
@ -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<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; }
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue