Queue chat bulk actions through SQL outbox

This commit is contained in:
sevenhill
2026-07-05 22:16:30 +03:00
parent f57faead53
commit dcc74252c6
10 changed files with 443 additions and 50 deletions
+38 -35
View File
@@ -26,7 +26,9 @@ public sealed class ChatsController(
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ChatDto>>> GetChats(CancellationToken cancellationToken)
{
var chats = (await db.Chats.ToArrayAsync(cancellationToken))
var chats = (await db.Chats
.Where(x => x.DeletedAt == null)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.IsPinned)
.ThenByDescending(x => x.UnreadCount > 0)
.ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
@@ -59,7 +61,7 @@ public sealed class ChatsController(
[HttpGet("{chatId:guid}/messages")]
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default)
{
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
return NotFound();
@@ -69,7 +71,7 @@ public sealed class ChatsController(
{
await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
db.ChangeTracker.Clear();
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
return NotFound();
@@ -104,7 +106,7 @@ public sealed class ChatsController(
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
return NotFound();
@@ -136,19 +138,7 @@ public sealed class ChatsController(
return NotFound();
}
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
"clear history",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
}
var now = DateTimeOffset.UtcNow;
var files = AttachmentFiles(chats).ToArray();
foreach (var chat in chats)
{
@@ -156,7 +146,12 @@ public sealed class ChatsController(
chat.LastMessageAt = null;
chat.LastMessagePreview = null;
chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow;
chat.HistoryClearedAt = now;
chat.UpdatedAt = now;
if (chat.DeletedAt is null)
{
QueuePendingMaxAction(chat, ChatPendingMaxAction.ClearHistory, now);
}
}
await db.SaveChangesAsync(cancellationToken);
@@ -180,21 +175,20 @@ public sealed class ChatsController(
return NotFound();
}
var now = DateTimeOffset.UtcNow;
var files = AttachmentFiles(chats).ToArray();
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
"delete chat",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
db.Messages.RemoveRange(chat.Messages);
chat.DeletedAt ??= now;
chat.HistoryClearedAt = now;
chat.LastMessageAt = null;
chat.LastMessagePreview = null;
chat.UnreadCount = 0;
chat.UpdatedAt = now;
QueuePendingMaxAction(chat, ChatPendingMaxAction.DeleteChat, now);
}
var files = AttachmentFiles(chats).ToArray();
db.Chats.RemoveRange(chats);
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
@@ -214,7 +208,7 @@ public sealed class ChatsController(
return BadRequest("Search query is required.");
}
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken);
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (!chatExists)
{
return NotFound();
@@ -242,7 +236,7 @@ public sealed class ChatsController(
{
var chat = await db.Chats
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl))
{
@@ -263,7 +257,7 @@ public sealed class ChatsController(
{
var chat = await db.Chats
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
@@ -285,7 +279,7 @@ public sealed class ChatsController(
[HttpPost("{chatId:guid}/messages")]
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
{
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
return NotFound();
@@ -527,7 +521,7 @@ public sealed class ChatsController(
return NotFound();
}
var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken);
var target = await db.Chats.FirstOrDefaultAsync(x => x.Id == request.TargetChatId && x.DeletedAt == null, cancellationToken);
if (target is null)
{
return NotFound();
@@ -600,7 +594,7 @@ public sealed class ChatsController(
[FromForm] Guid? replyToMessageId,
CancellationToken cancellationToken)
{
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
if (chat is null)
{
return NotFound();
@@ -809,6 +803,15 @@ public sealed class ChatsController(
error?.Contains("menu was not opened", StringComparison.OrdinalIgnoreCase) == true;
}
private static void QueuePendingMaxAction(Chat chat, ChatPendingMaxAction action, DateTimeOffset requestedAt)
{
chat.PendingMaxAction = action;
chat.PendingMaxActionRequestedAt = requestedAt;
chat.PendingMaxActionLastAttemptAt = null;
chat.PendingMaxActionAttempts = 0;
chat.PendingMaxActionError = null;
}
private async Task<List<Chat>> LoadChatsWithAttachmentsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken cancellationToken)
{
return await db.Chats