From 027a3fd0c43992b2a67001509ceadb97466a4c0a Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 27 Aug 2025 19:18:46 +0100 Subject: [PATCH] Implement bidirectional customer conversations with customer-based grouping and order tagging --- .../Extensions/ServiceCollectionExtensions.cs | 15 ++ LittleShop.Client/LittleShopClient.cs | 6 +- LittleShop.Client/Models/CustomerMessage.cs | 24 +++ LittleShop.Client/Services/IMessageService.cs | 11 ++ LittleShop.Client/Services/MessageService.cs | 87 ++++++++++ .../Admin/Controllers/MessagesController.cs | 72 +++++++++ .../Admin/Views/Messages/Customer.cshtml | 149 ++++++++++++++++++ .../Areas/Admin/Views/Messages/Index.cshtml | 86 ++++++++++ .../Areas/Admin/Views/Orders/Details.cshtml | 7 +- .../Areas/Admin/Views/Shared/_Layout.cshtml | 5 + .../Controllers/BotMessagesController.cs | 50 ++++++ LittleShop/Services/CustomerMessageService.cs | 68 ++++---- .../Services/ICustomerMessageService.cs | 1 + LittleShop/littleshop.db-shm | Bin 32768 -> 32768 bytes LittleShop/littleshop.db-wal | Bin 107152 -> 321392 bytes TeleBot/TeleBot/Handlers/CallbackHandler.cs | 39 +++++ TeleBot/TeleBot/Handlers/CommandHandler.cs | 33 ++++ TeleBot/TeleBot/Handlers/MessageHandler.cs | 58 ++++++- TeleBot/TeleBot/Models/UserSession.cs | 1 + TeleBot/TeleBot/Program.cs | 5 + TeleBot/TeleBot/Services/LittleShopService.cs | 96 +++++++---- .../Services/MessageDeliveryService.cs | 24 ++- TeleBot/TeleBot/TelegramBotService.cs | 11 +- TeleBot/TeleBot/UI/MenuBuilder.cs | 14 ++ TeleBot/TeleBot/UI/MessageFormatter.cs | 2 + 25 files changed, 794 insertions(+), 70 deletions(-) create mode 100644 LittleShop.Client/Models/CustomerMessage.cs create mode 100644 LittleShop.Client/Services/IMessageService.cs create mode 100644 LittleShop.Client/Services/MessageService.cs create mode 100644 LittleShop/Areas/Admin/Controllers/MessagesController.cs create mode 100644 LittleShop/Areas/Admin/Views/Messages/Customer.cshtml create mode 100644 LittleShop/Areas/Admin/Views/Messages/Index.cshtml diff --git a/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs b/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs index 86f5936..b4c49a0 100644 --- a/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs +++ b/LittleShop.Client/Extensions/ServiceCollectionExtensions.cs @@ -87,6 +87,21 @@ public static class ServiceCollectionExtensions var options = serviceProvider.GetRequiredService>().Value; return new RetryPolicyHandler(logger, options.MaxRetryAttempts); }); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddHttpMessageHandler() + .AddHttpMessageHandler(serviceProvider => + { + var logger = serviceProvider.GetRequiredService>(); + var options = serviceProvider.GetRequiredService>().Value; + return new RetryPolicyHandler(logger, options.MaxRetryAttempts); + }); // Register the main client services.AddScoped(); diff --git a/LittleShop.Client/LittleShopClient.cs b/LittleShop.Client/LittleShopClient.cs index 61503e3..0f17fc8 100644 --- a/LittleShop.Client/LittleShopClient.cs +++ b/LittleShop.Client/LittleShopClient.cs @@ -10,6 +10,7 @@ public interface ILittleShopClient ICatalogService Catalog { get; } IOrderService Orders { get; } ICustomerService Customers { get; } + IMessageService Messages { get; } } public class LittleShopClient : ILittleShopClient @@ -18,16 +19,19 @@ public class LittleShopClient : ILittleShopClient public ICatalogService Catalog { get; } public IOrderService Orders { get; } public ICustomerService Customers { get; } + public IMessageService Messages { get; } public LittleShopClient( IAuthenticationService authenticationService, ICatalogService catalogService, IOrderService orderService, - ICustomerService customerService) + ICustomerService customerService, + IMessageService messageService) { Authentication = authenticationService; Catalog = catalogService; Orders = orderService; Customers = customerService; + Messages = messageService; } } \ No newline at end of file diff --git a/LittleShop.Client/Models/CustomerMessage.cs b/LittleShop.Client/Models/CustomerMessage.cs new file mode 100644 index 0000000..41fe0e4 --- /dev/null +++ b/LittleShop.Client/Models/CustomerMessage.cs @@ -0,0 +1,24 @@ +namespace LittleShop.Client.Models; + +public class CustomerMessage +{ + public Guid Id { get; set; } + public Guid CustomerId { get; set; } + public long TelegramUserId { get; set; } + public string Subject { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public MessageType Type { get; set; } + public bool IsUrgent { get; set; } + public string? OrderReference { get; set; } + public DateTime CreatedAt { get; set; } +} + +public enum MessageType +{ + OrderUpdate = 0, + PaymentReminder = 1, + ShippingInfo = 2, + CustomerService = 3, + Marketing = 4, + SystemAlert = 5 +} \ No newline at end of file diff --git a/LittleShop.Client/Services/IMessageService.cs b/LittleShop.Client/Services/IMessageService.cs new file mode 100644 index 0000000..6e6a4dd --- /dev/null +++ b/LittleShop.Client/Services/IMessageService.cs @@ -0,0 +1,11 @@ +using LittleShop.Client.Models; + +namespace LittleShop.Client.Services; + +public interface IMessageService +{ + Task> GetPendingMessagesAsync(string platform = "Telegram"); + Task MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null); + Task MarkMessageAsFailedAsync(Guid messageId, string reason); + Task CreateCustomerMessageAsync(object messageData); +} \ No newline at end of file diff --git a/LittleShop.Client/Services/MessageService.cs b/LittleShop.Client/Services/MessageService.cs new file mode 100644 index 0000000..0a1aed1 --- /dev/null +++ b/LittleShop.Client/Services/MessageService.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using LittleShop.Client.Models; + +namespace LittleShop.Client.Services; + +public class MessageService : IMessageService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public MessageService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> GetPendingMessagesAsync(string platform = "Telegram") + { + try + { + var response = await _httpClient.GetAsync($"api/bot/messages/pending?platform={platform}"); + + if (response.IsSuccessStatusCode) + { + var messages = await response.Content.ReadFromJsonAsync>(); + return messages ?? new List(); + } + + _logger.LogWarning("Failed to get pending messages: {StatusCode}", response.StatusCode); + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting pending messages"); + return new List(); + } + } + + public async Task MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null) + { + try + { + var url = $"api/bot/messages/{messageId}/mark-sent"; + if (!string.IsNullOrEmpty(platformMessageId)) + { + url += $"?platformMessageId={platformMessageId}"; + } + + var response = await _httpClient.PostAsync(url, null); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking message {MessageId} as sent", messageId); + return false; + } + } + + public async Task MarkMessageAsFailedAsync(Guid messageId, string reason) + { + try + { + var response = await _httpClient.PostAsJsonAsync($"api/bot/messages/{messageId}/mark-failed", reason); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking message {MessageId} as failed", messageId); + return false; + } + } + + public async Task CreateCustomerMessageAsync(object messageData) + { + try + { + var response = await _httpClient.PostAsJsonAsync("api/bot/messages/customer-create", messageData); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating customer message"); + return false; + } + } +} \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Controllers/MessagesController.cs b/LittleShop/Areas/Admin/Controllers/MessagesController.cs new file mode 100644 index 0000000..fd818d2 --- /dev/null +++ b/LittleShop/Areas/Admin/Controllers/MessagesController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using LittleShop.DTOs; +using LittleShop.Services; +using LittleShop.Models; + +namespace LittleShop.Areas.Admin.Controllers; + +[Area("Admin")] +[Authorize(Policy = "AdminOnly")] +public class MessagesController : Controller +{ + private readonly ICustomerMessageService _messageService; + private readonly ILogger _logger; + + public MessagesController(ICustomerMessageService messageService, ILogger logger) + { + _messageService = messageService; + _logger = logger; + } + + public async Task Index() + { + var threads = await _messageService.GetActiveThreadsAsync(); + return View(threads); + } + + public async Task Customer(Guid id) + { + var conversation = await _messageService.GetMessageThreadAsync(id); + if (conversation == null) + { + return NotFound(); + } + + return View(conversation); + } + + [HttpPost] + public async Task Reply(Guid customerId, string content, bool isUrgent = false) + { + try + { + var createMessageDto = new CreateCustomerMessageDto + { + CustomerId = customerId, + Type = MessageType.CustomerService, + Subject = "Support Reply", // Simple subject for admin replies + Content = content, + IsUrgent = isUrgent, + Priority = isUrgent ? 1 : 5 + }; + + var message = await _messageService.CreateMessageAsync(createMessageDto); + if (message != null) + { + TempData["Success"] = "Reply sent successfully!"; + } + else + { + TempData["Error"] = "Failed to send reply."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending reply"); + TempData["Error"] = "An error occurred sending the reply."; + } + + return RedirectToAction("Customer", new { id = customerId }); + } +} \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Messages/Customer.cshtml b/LittleShop/Areas/Admin/Views/Messages/Customer.cshtml new file mode 100644 index 0000000..33e6289 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Messages/Customer.cshtml @@ -0,0 +1,149 @@ +@model LittleShop.DTOs.MessageThreadDto + +@{ + ViewData["Title"] = $"Conversation with {Model.CustomerName}"; +} + +
+
+
+
+

Conversation with @Model.CustomerName

+ + Back to Messages + +
+ + @if (TempData["Success"] != null) + { + + } + + @if (TempData["Error"] != null) + { + + } + +
+
+ +
+
+
Conversation with @Model.CustomerName
+ + @Model.MessageCount messages since @Model.StartedAt.ToString("MMM dd, yyyy") + +
+
+ @foreach (var message in Model.Messages.OrderBy(m => m.CreatedAt)) + { +
+
+
+
+
+ + @if (message.Direction == 0) + { + + @:Admin + } + else + { + + @Model.CustomerName + } + + + @message.CreatedAt.ToString("MMM dd, HH:mm") + +
+
+ @Html.Raw(message.Content.Replace("\n", "
")) +
+ @if (!string.IsNullOrEmpty(message.OrderReference)) + { + + } + @if (message.IsUrgent) + { + Urgent + } +
+
+
+
+ } +
+
+
+ +
+ +
+
+
Customer Information
+
+
+

Name: @Model.CustomerName

+

Messages: @Model.MessageCount

+

First Contact: @Model.StartedAt.ToString("MMM dd, yyyy HH:mm")

+

Last Message: @Model.LastMessageAt.ToString("MMM dd, yyyy HH:mm")

+ @if (Model.HasUnreadMessages) + { +
+ Has unread messages +
+ } + @if (Model.RequiresResponse) + { +
+ Requires response +
+ } +
+
+ + +
+
+
Send Reply
+
+
+
+ + +
+ + +
+ +
+ + +
+ + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Messages/Index.cshtml b/LittleShop/Areas/Admin/Views/Messages/Index.cshtml new file mode 100644 index 0000000..05eb221 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Messages/Index.cshtml @@ -0,0 +1,86 @@ +@model IEnumerable + +@{ + ViewData["Title"] = "Customer Messages"; +} + +
+
+
+
+

Customer Messages

+
+ + @if (!Model.Any()) + { +
+ No customer messages yet. +
+ } + else + { +
+
+
Active Conversations
+
+
+
+ + + + + + + + + + + + + @foreach (var thread in Model.OrderByDescending(t => t.LastMessageAt)) + { + + + + + + + + + } + +
CustomerSubjectLast MessageMessagesStatusActions
+ @thread.CustomerName +
Customer conversation +
+ Latest activity + + @thread.LastMessageAt.ToString("MMM dd, HH:mm") +
Started: @thread.StartedAt.ToString("MMM dd") +
+ @thread.MessageCount + @if (thread.HasUnreadMessages) + { + Unread + } + + @if (thread.RequiresResponse) + { + Needs Response + } + else + { + Up to date + } + + + View Conversation + +
+
+
+
+ } +
+
+
\ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Orders/Details.cshtml b/LittleShop/Areas/Admin/Views/Orders/Details.cshtml index b3bac4c..28c8f6f 100644 --- a/LittleShop/Areas/Admin/Views/Orders/Details.cshtml +++ b/LittleShop/Areas/Admin/Views/Orders/Details.cshtml @@ -12,8 +12,11 @@
@if (Model.Customer != null) { - } diff --git a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml index 85dd589..b4890d2 100644 --- a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml @@ -40,6 +40,11 @@ Orders +