Sync phone contacts and enable chat deletion
This commit is contained in:
@@ -59,4 +59,9 @@ public sealed record ContactDto(
|
||||
string DisplayName,
|
||||
string? AvatarUrl,
|
||||
string? PhoneNumber,
|
||||
string? Status);
|
||||
string? Status,
|
||||
bool IsSavedContact);
|
||||
|
||||
public sealed record PhoneContactDto(string PhoneNumber, string FirstName, string? LastName = null);
|
||||
public sealed record ImportPhoneContactsRequest(IReadOnlyList<PhoneContactDto> Contacts);
|
||||
public sealed record ContactUserRequest(string UserId);
|
||||
|
||||
@@ -51,8 +51,18 @@ public sealed class ChatsController(
|
||||
Kind = ChatKind.MaxDialog
|
||||
};
|
||||
db.Chats.Add(chat);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
chat.Title = request.Title;
|
||||
chat.AvatarUrl = request.AvatarUrl;
|
||||
chat.DeletedAt = null;
|
||||
chat.PendingMaxAction = null;
|
||||
chat.PendingMaxActionError = null;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return projection.ToDto(chat);
|
||||
@@ -274,7 +284,7 @@ public sealed class ChatsController(
|
||||
}
|
||||
|
||||
[HttpPost("delete")]
|
||||
public IActionResult DeleteChats(ChatBulkActionRequest request)
|
||||
public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chatIds = request.ChatIds.Distinct().ToArray();
|
||||
if (chatIds.Length == 0)
|
||||
@@ -282,7 +292,43 @@ public sealed class ChatsController(
|
||||
return BadRequest("chatIds are required.");
|
||||
}
|
||||
|
||||
return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
|
||||
var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken);
|
||||
if (chats.Count != chatIds.Length || chats.Any(chat => chat.DeletedAt is not null))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var files = AttachmentFiles(chats).ToArray();
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
db.Messages.RemoveRange(chat.Messages);
|
||||
chat.LastMessageAt = null;
|
||||
chat.LastMessagePreview = null;
|
||||
chat.UnreadCount = 0;
|
||||
chat.DeletedAt = now;
|
||||
chat.UpdatedAt = now;
|
||||
chat.PendingMaxAction = null;
|
||||
chat.PendingMaxActionError = null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
DeleteFiles(files);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/search")]
|
||||
|
||||
@@ -21,8 +21,62 @@ public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerB
|
||||
contact.DisplayName,
|
||||
contact.AvatarUrl,
|
||||
contact.PhoneNumber,
|
||||
contact.Status))
|
||||
contact.Status,
|
||||
contact.IsSavedContact))
|
||||
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<ActionResult<IReadOnlyList<ContactDto>>> ImportContacts(
|
||||
ImportPhoneContactsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = request.Contacts
|
||||
.Where(contact => !string.IsNullOrWhiteSpace(contact.PhoneNumber))
|
||||
.Take(5000)
|
||||
.Select(contact => new MaxPhoneContact(
|
||||
contact.PhoneNumber.Trim(),
|
||||
string.IsNullOrWhiteSpace(contact.FirstName) ? contact.PhoneNumber.Trim() : contact.FirstName.Trim(),
|
||||
contact.LastName?.Trim()))
|
||||
.ToArray();
|
||||
if (contacts.Length == 0)
|
||||
{
|
||||
return BadRequest("contacts with phone numbers are required.");
|
||||
}
|
||||
|
||||
var imported = await maxBridge.ImportContactsAsync(contacts, cancellationToken);
|
||||
return imported.Select(ToDto).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
public async Task<ActionResult<ContactDto>> AddContact(ContactUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserId))
|
||||
{
|
||||
return BadRequest("userId is required.");
|
||||
}
|
||||
var contact = await maxBridge.AddContactAsync(request.UserId.Trim(), cancellationToken);
|
||||
return contact is null ? BadRequest("MAX did not add the contact.") : ToDto(contact);
|
||||
}
|
||||
|
||||
[HttpPost("remove")]
|
||||
public async Task<IActionResult> RemoveContact(ContactUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserId))
|
||||
{
|
||||
return BadRequest("userId is required.");
|
||||
}
|
||||
var result = await maxBridge.RemoveContactAsync(request.UserId.Trim(), cancellationToken);
|
||||
return result.Success ? NoContent() : BadRequest(result.Error ?? "MAX did not remove the contact.");
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(MaxContact contact) => new(
|
||||
contact.UserId,
|
||||
contact.ExternalChatId,
|
||||
contact.DisplayName,
|
||||
contact.AvatarUrl,
|
||||
contact.PhoneNumber,
|
||||
contact.Status,
|
||||
contact.IsSavedContact);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,18 @@ public interface IMaxBridgeClient
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
|
||||
}
|
||||
Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
|
||||
}
|
||||
Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MaxContact?>(null);
|
||||
}
|
||||
Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(false, "Contact removal is not supported."));
|
||||
}
|
||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -80,4 +80,7 @@ public sealed record MaxContact(
|
||||
string DisplayName,
|
||||
string? AvatarUrl,
|
||||
string? PhoneNumber,
|
||||
string? Status);
|
||||
string? Status,
|
||||
bool IsSavedContact = false);
|
||||
|
||||
public sealed record MaxPhoneContact(string PhoneNumber, string FirstName, string? LastName);
|
||||
|
||||
@@ -48,10 +48,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>([
|
||||
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online")
|
||||
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online", true)
|
||||
]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
|
||||
{
|
||||
return FetchContactsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return (await FetchContactsAsync(cancellationToken)).FirstOrDefault();
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
||||
@@ -75,9 +90,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
|
||||
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(
|
||||
false,
|
||||
"Chat deletion is disabled because MAX does not remove chats from the web client."));
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||
|
||||
@@ -49,6 +49,29 @@ public sealed class WorkerMaxBridgeClient(
|
||||
?? Array.Empty<MaxContact>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MaxContact>> ImportContactsAsync(
|
||||
IReadOnlyList<MaxPhoneContact> contacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<IReadOnlyList<MaxContact>>(
|
||||
HttpMethod.Post,
|
||||
"/contacts/import",
|
||||
new { contacts },
|
||||
cancellationToken)
|
||||
?? Array.Empty<MaxContact>();
|
||||
}
|
||||
|
||||
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxContact>(HttpMethod.Post, "/contacts/add", new { userId }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(HttpMethod.Post, "/contacts/remove", new { userId }, cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty contact removal result.");
|
||||
}
|
||||
|
||||
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
|
||||
@@ -75,11 +98,14 @@ public sealed class WorkerMaxBridgeClient(
|
||||
?? new MaxActionResult(false, "Worker returned an empty clear history result.");
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(
|
||||
false,
|
||||
"Chat deletion is disabled because MAX does not remove chats from the web client."));
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/chat/delete",
|
||||
new { externalChatId, chatUrl },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty chat deletion result.");
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||
|
||||
@@ -125,9 +125,10 @@ public sealed class MaxOutboxService(
|
||||
chat,
|
||||
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
|
||||
cancellationToken),
|
||||
ChatPendingMaxAction.DeleteChat => new MaxActionResult(
|
||||
false,
|
||||
"Chat deletion is disabled because MAX does not remove chats from the web client."),
|
||||
ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync(
|
||||
chat,
|
||||
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
|
||||
cancellationToken),
|
||||
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user