Compare commits
No commits in common. "ed3159c05dfa8e889c213e2e3bc8fb982e35b462" and "567e7941543fe830c083beadd876544f5d9406f6" have entirely different histories.
ed3159c05d
...
567e794154
14 changed files with 6 additions and 630 deletions
|
@ -1,250 +0,0 @@
|
||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -26,9 +26,6 @@ public class DatabaseContext : DbContext
|
||||||
public DbSet<UserFlag> UserFlags { get; set; }
|
public DbSet<UserFlag> UserFlags { get; set; }
|
||||||
public DbSet<MemberFlag> MemberFlags { get; set; }
|
public DbSet<MemberFlag> MemberFlags { get; set; }
|
||||||
|
|
||||||
public DbSet<SupportConversation> SupportConversations { get; set; }
|
|
||||||
public DbSet<SupportMessage> SupportMessages { get; set; }
|
|
||||||
|
|
||||||
public DatabaseContext(Config config, ILoggerFactory? loggerFactory)
|
public DatabaseContext(Config config, ILoggerFactory? loggerFactory)
|
||||||
{
|
{
|
||||||
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
|
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
|
||||||
|
@ -91,9 +88,6 @@ public class DatabaseContext : DbContext
|
||||||
modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude();
|
modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude();
|
||||||
modelBuilder.Entity<MemberFlag>().Navigation(f => f.PrideFlag).AutoInclude();
|
modelBuilder.Entity<MemberFlag>().Navigation(f => f.PrideFlag).AutoInclude();
|
||||||
|
|
||||||
modelBuilder.Entity<SupportMessage>().Property(m => m.Attachments).HasColumnType("jsonb");
|
|
||||||
modelBuilder.Entity<SupportMessage>().Property(m => m.History).HasColumnType("jsonb");
|
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!)
|
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!)
|
||||||
.HasName("find_free_user_sid");
|
.HasName("find_free_user_sid");
|
||||||
|
|
|
@ -1,118 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Foxnouns.Backend.Database.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
[DbContext(typeof(DatabaseContext))]
|
|
||||||
[Migration("20241003150352_AddSupportConversations")]
|
|
||||||
public partial class AddSupportConversations : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "support_conversations",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
id = table.Column<long>(type: "bigint", nullable: false),
|
|
||||||
user_id = table.Column<long>(type: "bigint", nullable: false),
|
|
||||||
title = table.Column<string>(type: "text", nullable: false),
|
|
||||||
assigned_to_id = table.Column<long>(type: "bigint", nullable: true),
|
|
||||||
status = table.Column<int>(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<long>(type: "bigint", nullable: false),
|
|
||||||
conversation_id = table.Column<long>(type: "bigint", nullable: false),
|
|
||||||
user_id = table.Column<long>(type: "bigint", nullable: false),
|
|
||||||
is_anonymous = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
content = table.Column<string>(type: "text", nullable: true),
|
|
||||||
attachments = table.Column<List<SupportMessage.Attachment>>(
|
|
||||||
type: "jsonb",
|
|
||||||
nullable: false
|
|
||||||
),
|
|
||||||
history = table.Column<List<SupportMessage.HistoryEntry>>(
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(name: "support_messages");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "support_conversations");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -266,85 +266,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("pride_flags", (string)null);
|
b.ToTable("pride_flags", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.SupportConversation", b =>
|
|
||||||
{
|
|
||||||
b.Property<long>("Id")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<long?>("AssignedToId")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("assigned_to_id");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("status");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("title");
|
|
||||||
|
|
||||||
b.Property<long>("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<long>("Id")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<List<SupportMessage.Attachment>>("Attachments")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("attachments");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("content");
|
|
||||||
|
|
||||||
b.Property<long>("ConversationId")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("conversation_id");
|
|
||||||
|
|
||||||
b.Property<List<SupportMessage.HistoryEntry>>("History")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("history");
|
|
||||||
|
|
||||||
b.Property<bool>("IsAnonymous")
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasColumnName("is_anonymous");
|
|
||||||
|
|
||||||
b.Property<long>("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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -621,46 +542,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_pride_flags_users_user_id");
|
.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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
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<Attachment> Attachments { get; set; } = [];
|
|
||||||
public List<HistoryEntry> 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; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -51,12 +51,6 @@ public class ApiError(
|
||||||
}
|
}
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
public BadRequest(string field, ValidationError error)
|
|
||||||
: this(
|
|
||||||
"Error validating input",
|
|
||||||
new Dictionary<string, IEnumerable<ValidationError>> { { field, [error] } }
|
|
||||||
) { }
|
|
||||||
|
|
||||||
public JObject ToJson()
|
public JObject ToJson()
|
||||||
{
|
{
|
||||||
var o = new JObject
|
var o = new JObject
|
||||||
|
|
|
@ -98,7 +98,6 @@ public static class WebApplicationExtensions
|
||||||
.AddSingleton<MailService>()
|
.AddSingleton<MailService>()
|
||||||
.AddScoped<UserRendererService>()
|
.AddScoped<UserRendererService>()
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
.AddScoped<SupportRendererService>()
|
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
.AddScoped<KeyCacheService>()
|
||||||
.AddScoped<RemoteAuthService>()
|
.AddScoped<RemoteAuthService>()
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -92,9 +92,6 @@ public class UserRendererService(
|
||||||
user.CustomPreferences
|
user.CustomPreferences
|
||||||
);
|
);
|
||||||
|
|
||||||
public PartialUser? TryRenderPartialUser(User? user) =>
|
|
||||||
user != null ? RenderPartialUser(user) : null;
|
|
||||||
|
|
||||||
private string? AvatarUrlFor(User user) =>
|
private string? AvatarUrlFor(User user) =>
|
||||||
user.Avatar != null
|
user.Avatar != null
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
|
|
|
@ -7,7 +7,6 @@ public static class AuthUtils
|
||||||
{
|
{
|
||||||
public const string ClientCredentials = "client_credentials";
|
public const string ClientCredentials = "client_credentials";
|
||||||
public const string AuthorizationCode = "authorization_code";
|
public const string AuthorizationCode = "authorization_code";
|
||||||
|
|
||||||
private static readonly string[] ForbiddenSchemes =
|
private static readonly string[] ForbiddenSchemes =
|
||||||
[
|
[
|
||||||
"javascript",
|
"javascript",
|
||||||
|
@ -22,7 +21,6 @@ public static class AuthUtils
|
||||||
"user.read_hidden",
|
"user.read_hidden",
|
||||||
"user.read_privileged",
|
"user.read_privileged",
|
||||||
"user.update",
|
"user.update",
|
||||||
"user.support",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly string[] MemberScopes =
|
public static readonly string[] MemberScopes =
|
||||||
|
|
|
@ -9,25 +9,6 @@ namespace Foxnouns.Backend.Utils;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class ValidationUtils
|
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 =
|
private static readonly string[] InvalidUsernames =
|
||||||
[
|
[
|
||||||
"..",
|
"..",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
||||||
import { API_BASE, INTERNAL_API_BASE } from "~/env.server";
|
import { INTERNAL_API_BASE } from "~/env.server";
|
||||||
import { ApiError, ErrorCode } from "./api/error";
|
import { ApiError, ErrorCode } from "./api/error";
|
||||||
import { tokenCookieName } from "~/lib/utils";
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
|
@ -11,17 +11,12 @@ export type RequestParams = {
|
||||||
isInternal?: boolean;
|
isInternal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
||||||
|
|
||||||
export async function baseRequest(
|
export async function baseRequest(
|
||||||
method: RequestMethod,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
params: RequestParams = {},
|
params: RequestParams = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// Internal requests, unauthenticated requests, and GET requests bypass the rate limiting proxy.
|
const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : INTERNAL_API_BASE + "/v2";
|
||||||
// All other requests go through the proxy, and are rate limited.
|
|
||||||
let base = params.isInternal || !params.token || method === "GET" ? INTERNAL_API_BASE : API_BASE;
|
|
||||||
base += params.isInternal ? "/internal" : "/v2";
|
|
||||||
|
|
||||||
const url = `${base}${path}`;
|
const url = `${base}${path}`;
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
|
@ -48,12 +43,12 @@ export async function baseRequest(
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fastRequest(method: RequestMethod, path: string, params: RequestParams = {}) {
|
export async function fastRequest(method: string, path: string, params: RequestParams = {}) {
|
||||||
await baseRequest(method, path, params);
|
await baseRequest(method, path, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function serverRequest<T>(
|
export default async function serverRequest<T>(
|
||||||
method: RequestMethod,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
params: RequestParams = {},
|
params: RequestParams = {},
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Link,
|
Link,
|
||||||
Outlet,
|
Outlet,
|
||||||
useActionData,
|
useActionData,
|
||||||
|
useFetcher,
|
||||||
useRouteLoaderData,
|
useRouteLoaderData,
|
||||||
} from "@remix-run/react";
|
} from "@remix-run/react";
|
||||||
import { loader as settingsLoader } from "../settings/route";
|
import { loader as settingsLoader } from "../settings/route";
|
||||||
|
|
Loading…
Reference in a new issue