Implement comprehensive notification system for LittleShop
- Add admin PWA push notifications for order management - Integrate TeleBot customer messaging service - Add push notification endpoints and VAPID key support - Implement order status notifications throughout workflow - Add notification UI components in admin panel - Create TeleBotMessagingService for customer updates - Add WebPush configuration to appsettings - Fix compilation issues (BotStatus, BotContacts DbSet) - Add comprehensive testing documentation Features: - Real-time admin notifications for new orders and status changes - Customer order progress updates via TeleBot - Graceful failure handling for notification services - Test endpoints for notification system validation 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,7 @@ public class BotRecoveryController : Controller
|
||||
}
|
||||
|
||||
// Update bot statuses
|
||||
await _botService.UpdateBotStatusAsync(fromBotId, Enums.BotStatus.Retired);
|
||||
await _botService.UpdateBotStatusAsync(fromBotId, Enums.BotStatus.Deleted);
|
||||
|
||||
TempData["Success"] = $"Successfully migrated contacts from bot {fromBotId} to {toBotId}";
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
<script src="/lib/jquery/jquery.min.js"></script>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/js/pwa.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/modern-mobile.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
|
||||
@@ -11,10 +11,12 @@ namespace LittleShop.Controllers;
|
||||
public class PushNotificationController : ControllerBase
|
||||
{
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ITeleBotMessagingService _teleBotMessagingService;
|
||||
|
||||
public PushNotificationController(IPushNotificationService pushNotificationService)
|
||||
public PushNotificationController(IPushNotificationService pushNotificationService, ITeleBotMessagingService teleBotMessagingService)
|
||||
{
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_teleBotMessagingService = teleBotMessagingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -238,9 +240,63 @@ public class PushNotificationController : ControllerBase
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get TeleBot service status
|
||||
/// </summary>
|
||||
[HttpGet("telebot/status")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> GetTeleBotStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var isAvailable = await _teleBotMessagingService.IsAvailableAsync();
|
||||
return Ok(new
|
||||
{
|
||||
available = isAvailable,
|
||||
message = isAvailable ? "TeleBot service is available" : "TeleBot service is not available"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send test message to TeleBot customer
|
||||
/// </summary>
|
||||
[HttpPost("telebot/test")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> SendTeleBotTestMessage([FromBody] TeleBotTestMessageDto testDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await _teleBotMessagingService.SendTestMessageAsync(testDto.CustomerId, testDto.Message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "TeleBot test message sent successfully" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to send TeleBot test message. Check TeleBot service status." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class UnsubscribeDto
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class TeleBotTestMessageDto
|
||||
{
|
||||
public Guid CustomerId { get; set; }
|
||||
public string Message { get; set; } = "This is a test message from LittleShop admin!";
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class LittleShopContext : DbContext
|
||||
public DbSet<CustomerMessage> CustomerMessages { get; set; }
|
||||
public DbSet<PushSubscription> PushSubscriptions { get; set; }
|
||||
public DbSet<Review> Reviews { get; set; }
|
||||
public DbSet<BotContact> BotContacts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="AutoMapper" Version="13.0.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
|
||||
|
||||
@@ -84,9 +84,12 @@ builder.Services.AddScoped<IReviewService, ReviewService>();
|
||||
builder.Services.AddScoped<IDataSeederService, DataSeederService>();
|
||||
builder.Services.AddScoped<IBotService, BotService>();
|
||||
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
|
||||
builder.Services.AddScoped<IBotContactService, BotContactService>();
|
||||
builder.Services.AddScoped<IMessageDeliveryService, MessageDeliveryService>();
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();
|
||||
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
|
||||
builder.Services.AddHttpClient<ITeleBotMessagingService, TeleBotMessagingService>();
|
||||
builder.Services.AddScoped<IProductImportService, ProductImportService>();
|
||||
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ using Microsoft.Extensions.Logging;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface IMessageDeliveryService
|
||||
{
|
||||
// Placeholder interface for compilation
|
||||
Task<bool> QueueRecoveryMessageAsync(long telegramUserId, string message);
|
||||
}
|
||||
|
||||
public interface IBotContactService
|
||||
|
||||
@@ -14,17 +14,23 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
private readonly IBTCPayServerService _btcPayService;
|
||||
private readonly ILogger<CryptoPaymentService> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ITeleBotMessagingService _teleBotMessagingService;
|
||||
|
||||
public CryptoPaymentService(
|
||||
LittleShopContext context,
|
||||
IBTCPayServerService btcPayService,
|
||||
ILogger<CryptoPaymentService> logger,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
IPushNotificationService pushNotificationService,
|
||||
ITeleBotMessagingService teleBotMessagingService)
|
||||
{
|
||||
_context = context;
|
||||
_btcPayService = btcPayService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_teleBotMessagingService = teleBotMessagingService;
|
||||
}
|
||||
|
||||
public async Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency)
|
||||
@@ -157,9 +163,11 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
if (status == PaymentStatus.Paid)
|
||||
{
|
||||
payment.PaidAt = DateTime.UtcNow;
|
||||
|
||||
|
||||
// Update order status
|
||||
var order = await _context.Orders.FindAsync(payment.OrderId);
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == payment.OrderId);
|
||||
if (order != null)
|
||||
{
|
||||
order.Status = OrderStatus.PaymentReceived;
|
||||
@@ -169,7 +177,13 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Processed payment webhook for invoice {InvoiceId}, status: {Status}",
|
||||
// Send notification for payment confirmation
|
||||
if (status == PaymentStatus.Paid)
|
||||
{
|
||||
await SendPaymentConfirmedNotification(payment.OrderId, amount);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processed payment webhook for invoice {InvoiceId}, status: {Status}",
|
||||
invoiceId, status);
|
||||
|
||||
return true;
|
||||
@@ -209,4 +223,25 @@ public class CryptoPaymentService : ICryptoPaymentService
|
||||
_ => "BTC"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SendPaymentConfirmedNotification(Guid orderId, decimal amount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var title = "💰 Payment Confirmed";
|
||||
var body = $"Order #{orderId.ToString()[..8]} payment of £{amount:F2} confirmed. Ready for acceptance.";
|
||||
|
||||
// Send push notification to admin users
|
||||
await _pushNotificationService.SendOrderNotificationAsync(orderId, title, body);
|
||||
|
||||
// Send TeleBot message to customer
|
||||
await _teleBotMessagingService.SendPaymentConfirmedAsync(orderId);
|
||||
|
||||
_logger.LogInformation("Sent payment confirmation notifications for order {OrderId} (Admin + Customer)", orderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send payment confirmation notification for order {OrderId}", orderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
LittleShop/Services/ITeleBotMessagingService.cs
Normal file
16
LittleShop/Services/ITeleBotMessagingService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface ITeleBotMessagingService
|
||||
{
|
||||
Task<bool> SendOrderStatusUpdateAsync(Guid orderId, OrderStatus newStatus);
|
||||
Task<bool> SendPaymentConfirmedAsync(Guid orderId);
|
||||
Task<bool> SendOrderAcceptedAsync(Guid orderId);
|
||||
Task<bool> SendOrderPackingAsync(Guid orderId);
|
||||
Task<bool> SendOrderDispatchedAsync(Guid orderId, string? trackingNumber = null);
|
||||
Task<bool> SendOrderDeliveredAsync(Guid orderId);
|
||||
Task<bool> SendOrderOnHoldAsync(Guid orderId, string? reason = null);
|
||||
Task<bool> SendTestMessageAsync(Guid customerId, string message);
|
||||
Task<bool> IsAvailableAsync();
|
||||
}
|
||||
25
LittleShop/Services/MessageDeliveryService.cs
Normal file
25
LittleShop/Services/MessageDeliveryService.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public class MessageDeliveryService : IMessageDeliveryService
|
||||
{
|
||||
private readonly ILogger<MessageDeliveryService> _logger;
|
||||
|
||||
public MessageDeliveryService(ILogger<MessageDeliveryService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> QueueRecoveryMessageAsync(long telegramUserId, string message)
|
||||
{
|
||||
_logger.LogInformation("Queuing recovery message for user {UserId}: {Message}", telegramUserId, message);
|
||||
|
||||
// Placeholder implementation - would integrate with actual messaging system
|
||||
await Task.Delay(100);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,16 @@ public class OrderService : IOrderService
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ITeleBotMessagingService _teleBotMessagingService;
|
||||
|
||||
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService)
|
||||
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService, IPushNotificationService pushNotificationService, ITeleBotMessagingService teleBotMessagingService)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_customerService = customerService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_teleBotMessagingService = teleBotMessagingService;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
|
||||
@@ -179,15 +183,18 @@ public class OrderService : IOrderService
|
||||
|
||||
if (customerId.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
|
||||
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
|
||||
order.Id, customerId.Value, totalAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
|
||||
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
|
||||
order.Id, identityReference, totalAmount);
|
||||
}
|
||||
|
||||
// Send notification about new order to admin users
|
||||
await SendNewOrderNotification(order);
|
||||
|
||||
// Reload order with includes
|
||||
var createdOrder = await GetOrderByIdAsync(order.Id);
|
||||
return createdOrder!;
|
||||
@@ -201,11 +208,14 @@ public class OrderService : IOrderService
|
||||
|
||||
public async Task<bool> UpdateOrderStatusAsync(Guid id, UpdateOrderStatusDto updateOrderStatusDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
if (order == null) return false;
|
||||
|
||||
var previousStatus = order.Status;
|
||||
order.Status = updateOrderStatusDto.Status;
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(updateOrderStatusDto.TrackingNumber))
|
||||
{
|
||||
order.TrackingNumber = updateOrderStatusDto.TrackingNumber;
|
||||
@@ -225,7 +235,10 @@ public class OrderService : IOrderService
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated order {OrderId} status to {Status}", id, updateOrderStatusDto.Status);
|
||||
_logger.LogInformation("Updated order {OrderId} status from {PreviousStatus} to {NewStatus}", id, previousStatus, updateOrderStatusDto.Status);
|
||||
|
||||
// Send push notifications for status changes
|
||||
await SendOrderStatusNotification(order, previousStatus, updateOrderStatusDto.Status);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -336,10 +349,13 @@ public class OrderService : IOrderService
|
||||
// Enhanced workflow methods
|
||||
public async Task<bool> AcceptOrderAsync(Guid id, string userName, AcceptOrderDto acceptDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
if (order == null || order.Status != OrderStatus.PaymentReceived)
|
||||
return false;
|
||||
|
||||
var previousStatus = order.Status;
|
||||
order.Status = OrderStatus.Accepted;
|
||||
order.AcceptedAt = DateTime.UtcNow;
|
||||
order.AcceptedByUser = userName;
|
||||
@@ -349,15 +365,22 @@ public class OrderService : IOrderService
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} accepted by {User}", id, userName);
|
||||
|
||||
// Send push notifications
|
||||
await SendOrderStatusNotification(order, previousStatus, OrderStatus.Accepted);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> StartPackingAsync(Guid id, string userName, StartPackingDto packingDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
if (order == null || order.Status != OrderStatus.Accepted)
|
||||
return false;
|
||||
|
||||
var previousStatus = order.Status;
|
||||
order.Status = OrderStatus.Packing;
|
||||
order.PackingStartedAt = DateTime.UtcNow;
|
||||
order.PackedByUser = userName;
|
||||
@@ -367,12 +390,18 @@ public class OrderService : IOrderService
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} packing started by {User}", id, userName);
|
||||
|
||||
// Send push notifications
|
||||
await SendOrderStatusNotification(order, previousStatus, OrderStatus.Packing);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchOrderAsync(Guid id, string userName, DispatchOrderDto dispatchDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
var order = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
if (order == null || order.Status != OrderStatus.Packing)
|
||||
return false;
|
||||
|
||||
@@ -398,6 +427,10 @@ public class OrderService : IOrderService
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} dispatched by {User} with tracking {TrackingNumber}", id, userName, dispatchDto.TrackingNumber);
|
||||
|
||||
// Send push notifications
|
||||
await SendOrderStatusNotification(order, OrderStatus.Packing, OrderStatus.Dispatched);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -490,4 +523,81 @@ public class OrderService : IOrderService
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
|
||||
}
|
||||
|
||||
private async Task SendNewOrderNotification(Order order)
|
||||
{
|
||||
try
|
||||
{
|
||||
var title = "🛒 New Order Received";
|
||||
var body = $"Order #{order.Id.ToString()[..8]} created for £{order.TotalAmount:F2}. Awaiting payment.";
|
||||
|
||||
// Send notification to all admin users about new order
|
||||
await _pushNotificationService.SendOrderNotificationAsync(order.Id, title, body);
|
||||
|
||||
_logger.LogInformation("Sent new order notification for order {OrderId}", order.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send new order notification for order {OrderId}", order.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendOrderStatusNotification(Order order, OrderStatus previousStatus, OrderStatus newStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
var title = GetOrderStatusNotificationTitle(newStatus);
|
||||
var body = GetOrderStatusNotificationBody(order, previousStatus, newStatus);
|
||||
|
||||
// Send notification to admin users about order status change
|
||||
await _pushNotificationService.SendOrderNotificationAsync(order.Id, title, body);
|
||||
|
||||
// Send TeleBot message to customer (if customer exists)
|
||||
if (order.Customer != null)
|
||||
{
|
||||
await _teleBotMessagingService.SendOrderStatusUpdateAsync(order.Id, newStatus);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sent order status notifications for order {OrderId}: {Status} (Admin + Customer)", order.Id, newStatus);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send order status notification for order {OrderId}", order.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOrderStatusNotificationTitle(OrderStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
OrderStatus.PaymentReceived => "💰 Payment Confirmed",
|
||||
OrderStatus.Accepted => "✅ Order Accepted",
|
||||
OrderStatus.Packing => "📦 Being Packed",
|
||||
OrderStatus.Dispatched => "🚚 Order Dispatched",
|
||||
OrderStatus.Delivered => "🎉 Order Delivered",
|
||||
OrderStatus.OnHold => "⏸️ Order On Hold",
|
||||
OrderStatus.Cancelled => "❌ Order Cancelled",
|
||||
OrderStatus.Refunded => "💸 Order Refunded",
|
||||
_ => "📋 Order Updated"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetOrderStatusNotificationBody(Order order, OrderStatus previousStatus, OrderStatus newStatus)
|
||||
{
|
||||
var orderId = order.Id.ToString()[..8];
|
||||
var amount = order.TotalAmount.ToString("F2");
|
||||
|
||||
return newStatus switch
|
||||
{
|
||||
OrderStatus.PaymentReceived => $"Order #{orderId} payment confirmed (£{amount}). Ready for acceptance.",
|
||||
OrderStatus.Accepted => $"Order #{orderId} has been accepted and is ready for packing.",
|
||||
OrderStatus.Packing => $"Order #{orderId} is being packed. Will be dispatched soon.",
|
||||
OrderStatus.Dispatched => $"Order #{orderId} dispatched with tracking: {order.TrackingNumber ?? "TBA"}",
|
||||
OrderStatus.Delivered => $"Order #{orderId} has been delivered successfully.",
|
||||
OrderStatus.OnHold => $"Order #{orderId} has been put on hold: {order.OnHoldReason}",
|
||||
OrderStatus.Cancelled => $"Order #{orderId} has been cancelled.",
|
||||
OrderStatus.Refunded => $"Order #{orderId} has been refunded (£{amount}).",
|
||||
_ => $"Order #{orderId} status updated from {previousStatus} to {newStatus}."
|
||||
};
|
||||
}
|
||||
}
|
||||
218
LittleShop/Services/TeleBotMessagingService.cs
Normal file
218
LittleShop/Services/TeleBotMessagingService.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Enums;
|
||||
using LittleShop.Models;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public class TeleBotMessagingService : ITeleBotMessagingService
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<TeleBotMessagingService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string? _teleBotApiUrl;
|
||||
private readonly string? _teleBotApiKey;
|
||||
|
||||
public TeleBotMessagingService(
|
||||
LittleShopContext context,
|
||||
IConfiguration configuration,
|
||||
ILogger<TeleBotMessagingService> logger,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
|
||||
_teleBotApiUrl = _configuration["TeleBot:ApiUrl"];
|
||||
_teleBotApiKey = _configuration["TeleBot:ApiKey"];
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderStatusUpdateAsync(Guid orderId, OrderStatus newStatus)
|
||||
{
|
||||
return newStatus switch
|
||||
{
|
||||
OrderStatus.PaymentReceived => await SendPaymentConfirmedAsync(orderId),
|
||||
OrderStatus.Accepted => await SendOrderAcceptedAsync(orderId),
|
||||
OrderStatus.Packing => await SendOrderPackingAsync(orderId),
|
||||
OrderStatus.Dispatched => await SendOrderDispatchedAsync(orderId),
|
||||
OrderStatus.Delivered => await SendOrderDeliveredAsync(orderId),
|
||||
OrderStatus.OnHold => await SendOrderOnHoldAsync(orderId),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> SendPaymentConfirmedAsync(Guid orderId)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var message = $"💰 *Payment Confirmed!*\n\n" +
|
||||
$"Your order #{orderId.ToString()[..8]} has been paid successfully. " +
|
||||
$"We'll start processing it shortly.\n\n" +
|
||||
$"📦 Total: £{order.TotalAmount:F2}\n" +
|
||||
$"⏱️ Expected processing: Within 24 hours";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderAcceptedAsync(Guid orderId)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var message = $"✅ *Order Accepted!*\n\n" +
|
||||
$"Great news! Your order #{orderId.ToString()[..8]} has been accepted " +
|
||||
$"and is being prepared for packing.\n\n" +
|
||||
$"⏱️ Expected packing: Within 24 hours\n" +
|
||||
$"🚚 We'll notify you when it's dispatched";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderPackingAsync(Guid orderId)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var message = $"📦 *Being Packed!*\n\n" +
|
||||
$"Your order #{orderId.ToString()[..8]} is currently being packed with care.\n\n" +
|
||||
$"🚚 We'll send tracking details once dispatched.\n" +
|
||||
$"⏱️ Expected dispatch: Later today";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderDispatchedAsync(Guid orderId, string? trackingNumber = null)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var trackingInfo = !string.IsNullOrEmpty(trackingNumber)
|
||||
? $"📍 Tracking: `{trackingNumber}`\n"
|
||||
: "";
|
||||
|
||||
var message = $"🚚 *Order Dispatched!*\n\n" +
|
||||
$"Your order #{orderId.ToString()[..8]} is on its way!\n\n" +
|
||||
$"{trackingInfo}" +
|
||||
$"⏱️ Estimated delivery: 1-3 working days\n" +
|
||||
$"📍 Track your package for real-time updates";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderDeliveredAsync(Guid orderId)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var message = $"🎉 *Order Delivered!*\n\n" +
|
||||
$"Your order #{orderId.ToString()[..8]} has been delivered successfully!\n\n" +
|
||||
$"⭐ Please consider leaving a review using the /review command.\n" +
|
||||
$"🛒 Thank you for choosing us for your order!";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendOrderOnHoldAsync(Guid orderId, string? reason = null)
|
||||
{
|
||||
var order = await GetOrderWithCustomerAsync(orderId);
|
||||
if (order?.Customer == null) return false;
|
||||
|
||||
var reasonText = !string.IsNullOrEmpty(reason)
|
||||
? $"\n\n📝 Reason: {reason}"
|
||||
: "";
|
||||
|
||||
var message = $"⏸️ *Order On Hold*\n\n" +
|
||||
$"Your order #{orderId.ToString()[..8]} has been temporarily put on hold.{reasonText}\n\n" +
|
||||
$"💬 Please contact support if you have any questions.\n" +
|
||||
$"⏱️ We'll resolve this as quickly as possible";
|
||||
|
||||
return await SendTeleBotMessageAsync(order.Customer.TelegramUserId, message);
|
||||
}
|
||||
|
||||
public async Task<bool> SendTestMessageAsync(Guid customerId, string message)
|
||||
{
|
||||
var customer = await _context.Customers.FindAsync(customerId);
|
||||
if (customer == null) return false;
|
||||
|
||||
var testMessage = $"🧪 *Test Message*\n\n{message}";
|
||||
return await SendTeleBotMessageAsync(customer.TelegramUserId, testMessage);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_teleBotApiUrl) || string.IsNullOrEmpty(_teleBotApiKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"{_teleBotApiUrl}/health");
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Order?> GetOrderWithCustomerAsync(Guid orderId)
|
||||
{
|
||||
return await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
}
|
||||
|
||||
private async Task<bool> SendTeleBotMessageAsync(long telegramUserId, string message)
|
||||
{
|
||||
if (!await IsAvailableAsync())
|
||||
{
|
||||
_logger.LogWarning("TeleBot API not available, skipping message to user {UserId}", telegramUserId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestData = new
|
||||
{
|
||||
userId = telegramUserId,
|
||||
message = message,
|
||||
parseMode = "Markdown"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestData);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Add API key header
|
||||
_httpClient.DefaultRequestHeaders.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Add("X-API-Key", _teleBotApiKey);
|
||||
|
||||
var response = await _httpClient.PostAsync($"{_teleBotApiUrl}/api/messages/send", content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Successfully sent TeleBot message to user {UserId}", telegramUserId);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to send TeleBot message to user {UserId}: {StatusCode} - {Response}",
|
||||
telegramUserId, response.StatusCode, responseContent);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending TeleBot message to user {UserId}", telegramUserId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,9 @@
|
||||
"http://localhost:5001",
|
||||
"https://localhost:5001"
|
||||
]
|
||||
},
|
||||
"TeleBot": {
|
||||
"ApiUrl": "http://localhost:8080",
|
||||
"ApiKey": "development-key-replace-in-production"
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@
|
||||
"ForwardedForHeaderName": "X-Forwarded-For",
|
||||
"ForwardedHostHeaderName": "X-Forwarded-Host"
|
||||
},
|
||||
"TeleBot": {
|
||||
"ApiUrl": "${TELEBOT_API_URL}",
|
||||
"ApiKey": "${TELEBOT_API_KEY}"
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||
"MinimumLevel": "Information",
|
||||
|
||||
317
LittleShop/wwwroot/js/notifications.js
Normal file
317
LittleShop/wwwroot/js/notifications.js
Normal file
@@ -0,0 +1,317 @@
|
||||
// Enhanced notification management for LittleShop Admin
|
||||
// Handles real-time order notifications and admin alerts
|
||||
|
||||
class AdminNotificationManager {
|
||||
constructor() {
|
||||
this.isSetupComplete = false;
|
||||
this.notificationQueue = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('Admin Notifications: Initializing...');
|
||||
|
||||
// Wait for PWA manager to be ready
|
||||
if (window.pwaManager) {
|
||||
await this.setupOrderNotifications();
|
||||
} else {
|
||||
// Wait for PWA manager to load
|
||||
setTimeout(() => this.init(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async setupOrderNotifications() {
|
||||
try {
|
||||
// Ensure push notifications are enabled
|
||||
if (!window.pwaManager.pushSubscription) {
|
||||
console.log('Admin Notifications: Setting up push notifications...');
|
||||
|
||||
// Show admin-specific notification prompt
|
||||
this.showAdminNotificationPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSetupComplete = true;
|
||||
this.addNotificationStatusIndicator();
|
||||
this.setupTestNotificationButton();
|
||||
|
||||
console.log('Admin Notifications: Setup complete');
|
||||
} catch (error) {
|
||||
console.error('Admin Notifications: Setup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showAdminNotificationPrompt() {
|
||||
// Check if prompt already exists
|
||||
if (document.getElementById('admin-notification-prompt')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptDiv = document.createElement('div');
|
||||
promptDiv.id = 'admin-notification-prompt';
|
||||
promptDiv.className = 'alert alert-warning alert-dismissible position-fixed';
|
||||
promptDiv.style.cssText = `
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1055;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
`;
|
||||
|
||||
promptDiv.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-bell-slash text-warning me-3 fa-2x"></i>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="alert-heading mb-1">Enable Order Notifications</h6>
|
||||
<p class="mb-2">Get instant alerts for new orders, payments, and status changes.</p>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-warning btn-sm" id="enable-admin-notifications">
|
||||
<i class="fas fa-bell me-1"></i>Enable Now
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="remind-later">
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(promptDiv);
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('enable-admin-notifications').addEventListener('click', async () => {
|
||||
try {
|
||||
await this.enableNotifications();
|
||||
promptDiv.remove();
|
||||
} catch (error) {
|
||||
console.error('Failed to enable notifications:', error);
|
||||
this.showNotificationError('Failed to enable notifications. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('remind-later').addEventListener('click', () => {
|
||||
promptDiv.remove();
|
||||
// Set reminder for 1 hour
|
||||
setTimeout(() => this.showAdminNotificationPrompt(), 60 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
async enableNotifications() {
|
||||
const button = document.getElementById('enable-admin-notifications');
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enabling...';
|
||||
|
||||
try {
|
||||
await window.pwaManager.subscribeToPushNotifications();
|
||||
|
||||
// Show success message
|
||||
this.showNotificationSuccess('✅ Order notifications enabled successfully!');
|
||||
|
||||
// Complete setup
|
||||
await this.setupOrderNotifications();
|
||||
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
addNotificationStatusIndicator() {
|
||||
// Add status indicator to admin header/navbar
|
||||
const navbar = document.querySelector('.navbar-nav');
|
||||
if (!navbar || document.getElementById('notification-status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusItem = document.createElement('li');
|
||||
statusItem.className = 'nav-item dropdown';
|
||||
statusItem.innerHTML = `
|
||||
<a class="nav-link dropdown-toggle" href="#" id="notification-status" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-bell text-success"></i>
|
||||
<span class="d-none d-md-inline ms-1">Notifications</span>
|
||||
<span id="notification-badge" class="badge bg-danger ms-1" style="display: none;">0</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><h6 class="dropdown-header">Notification Settings</h6></li>
|
||||
<li><a class="dropdown-item" href="#" id="test-notification">
|
||||
<i class="fas fa-vial me-2"></i>Send Test Notification
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="#" id="notification-history">
|
||||
<i class="fas fa-history me-2"></i>Recent Notifications
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" id="disable-notifications">
|
||||
<i class="fas fa-bell-slash me-2"></i>Disable Notifications
|
||||
</a></li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
navbar.appendChild(statusItem);
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('test-notification').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.sendTestNotification();
|
||||
});
|
||||
|
||||
document.getElementById('disable-notifications').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.disableNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
setupTestNotificationButton() {
|
||||
// Add test button to dashboard if we're on the dashboard page
|
||||
const dashboardContent = document.querySelector('.dashboard-content, .admin-dashboard');
|
||||
if (!dashboardContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testButton = document.createElement('button');
|
||||
testButton.className = 'btn btn-outline-primary btn-sm me-2';
|
||||
testButton.innerHTML = '<i class="fas fa-bell me-1"></i>Test Notification';
|
||||
testButton.onclick = () => this.sendTestNotification();
|
||||
|
||||
// Find a good place to add it (e.g., near page title)
|
||||
const pageTitle = document.querySelector('h1, .page-title');
|
||||
if (pageTitle) {
|
||||
pageTitle.parentNode.insertBefore(testButton, pageTitle.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestNotification() {
|
||||
try {
|
||||
const response = await fetch('/api/push/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '🧪 Test Notification',
|
||||
body: 'LittleShop admin notifications are working perfectly!'
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotificationSuccess('Test notification sent!');
|
||||
} else {
|
||||
throw new Error('Failed to send test notification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test notification failed:', error);
|
||||
this.showNotificationError('Failed to send test notification');
|
||||
}
|
||||
}
|
||||
|
||||
async disableNotifications() {
|
||||
if (confirm('Are you sure you want to disable order notifications?')) {
|
||||
try {
|
||||
await window.pwaManager.unsubscribeFromPushNotifications();
|
||||
|
||||
// Remove status indicator
|
||||
const statusElement = document.getElementById('notification-status');
|
||||
if (statusElement) {
|
||||
statusElement.closest('.nav-item').remove();
|
||||
}
|
||||
|
||||
this.showNotificationSuccess('Notifications disabled');
|
||||
|
||||
// Reset setup status
|
||||
this.isSetupComplete = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to disable notifications:', error);
|
||||
this.showNotificationError('Failed to disable notifications');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showNotificationSuccess(message) {
|
||||
this.showToast(message, 'success');
|
||||
}
|
||||
|
||||
showNotificationError(message) {
|
||||
this.showToast(message, 'danger');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} alert-dismissible position-fixed`;
|
||||
toast.style.cssText = `
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1060;
|
||||
min-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
`;
|
||||
toast.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Handle incoming notifications (if using WebSocket/SignalR in future)
|
||||
handleOrderNotification(data) {
|
||||
if (!this.isSetupComplete) {
|
||||
this.notificationQueue.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update notification badge
|
||||
this.updateNotificationBadge();
|
||||
|
||||
// Show browser notification if page is not visible
|
||||
if (document.hidden && window.pwaManager) {
|
||||
window.pwaManager.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192x192.png',
|
||||
badge: '/icons/icon-72x72.png',
|
||||
tag: 'order-notification',
|
||||
requireInteraction: true,
|
||||
actions: [
|
||||
{ action: 'view', title: 'View Order' },
|
||||
{ action: 'dismiss', title: 'Dismiss' }
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateNotificationBadge(count = null) {
|
||||
const badge = document.getElementById('notification-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (count === null) {
|
||||
// Get current count and increment
|
||||
const currentCount = parseInt(badge.textContent) || 0;
|
||||
count = currentCount + 1;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
badge.textContent = count;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize admin notification manager
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.adminNotificationManager = new AdminNotificationManager();
|
||||
});
|
||||
|
||||
// Export for global access
|
||||
window.AdminNotificationManager = AdminNotificationManager;
|
||||
Reference in New Issue
Block a user