dcdb0d5747
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m9s
Guest orders from the QR/digital menu already notified via SignalR, but only screens that were open (KDS/POS/tables) reacted — and silently (a data refresh, no alert). So staff on any other screen never knew a menu order arrived. - Add a global useOrderAlerts() mounted in the dashboard shell: connects to /hubs/kds, joins the café group, and on a new GUEST order plays a chime + shows a toast (localized fa/en/ar) + nudges order/KDS/POS lists to refresh — on every screen. - Filter to guest QR-menu orders only (not staff POS orders): LiveOrderDto now carries Source, set in MapLiveOrder (+ the delivery/snappfood mappers). 86 API tests pass; dashboard tsc + build clean.
189 lines
6.3 KiB
C#
189 lines
6.3 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Meezi.API.Models.Orders;
|
|
using Meezi.API.Services.Printing;
|
|
using Meezi.API.Models.Snappfood;
|
|
using Meezi.Core.Entities;
|
|
using Meezi.Core.Enums;
|
|
using Meezi.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Meezi.API.Services;
|
|
|
|
public interface ISnappfoodWebhookService
|
|
{
|
|
bool VerifySignature(string rawBody, string? signatureHeader);
|
|
Task<(bool Success, string? Error)> ProcessOrderAsync(SnappfoodWebhookOrder order, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public class SnappfoodWebhookService : ISnappfoodWebhookService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IKdsNotifier _kdsNotifier;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SnappfoodWebhookService> _logger;
|
|
|
|
public SnappfoodWebhookService(
|
|
AppDbContext db,
|
|
IKdsNotifier kdsNotifier,
|
|
IConfiguration configuration,
|
|
ILogger<SnappfoodWebhookService> logger)
|
|
{
|
|
_db = db;
|
|
_kdsNotifier = kdsNotifier;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
public bool VerifySignature(string rawBody, string? signatureHeader)
|
|
{
|
|
var secret = _configuration["Snappfood:WebhookSecret"];
|
|
if (string.IsNullOrWhiteSpace(secret))
|
|
return true;
|
|
|
|
if (string.IsNullOrWhiteSpace(signatureHeader))
|
|
return false;
|
|
|
|
var expected = ComputeHmac(rawBody, secret);
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(expected),
|
|
Encoding.UTF8.GetBytes(signatureHeader.Trim()));
|
|
}
|
|
|
|
public async Task<(bool Success, string? Error)> ProcessOrderAsync(
|
|
SnappfoodWebhookOrder order,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var cafe = await _db.Cafes
|
|
.FirstOrDefaultAsync(c => c.SnappfoodVendorId == order.VendorId, cancellationToken);
|
|
|
|
if (cafe is null)
|
|
return (false, "Unknown vendor.");
|
|
|
|
var existing = await _db.Orders
|
|
.AnyAsync(o => o.CafeId == cafe.Id && o.SnappfoodOrderId == order.OrderId, cancellationToken);
|
|
if (existing)
|
|
return (true, null);
|
|
|
|
var menuItems = await _db.MenuItems
|
|
.Where(m => m.CafeId == cafe.Id && m.IsAvailable)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var orderItems = new List<OrderItem>();
|
|
decimal subtotal = 0;
|
|
|
|
foreach (var item in order.Items)
|
|
{
|
|
var menuItem = menuItems.FirstOrDefault(m =>
|
|
m.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase) ||
|
|
(m.NameEn != null && m.NameEn.Equals(item.Name, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
if (menuItem is null)
|
|
{
|
|
_logger.LogWarning("Snappfood item {Name} not matched for cafe {CafeId}", item.Name, cafe.Id);
|
|
continue;
|
|
}
|
|
|
|
var lineTotal = item.UnitPrice * item.Quantity;
|
|
subtotal += lineTotal;
|
|
orderItems.Add(new OrderItem
|
|
{
|
|
MenuItemId = menuItem.Id,
|
|
Quantity = item.Quantity,
|
|
UnitPrice = item.UnitPrice,
|
|
Notes = "Snappfood"
|
|
});
|
|
}
|
|
|
|
if (orderItems.Count == 0)
|
|
return (false, "No menu items matched.");
|
|
|
|
var taxRate = await _db.Taxes
|
|
.Where(t => t.CafeId == cafe.Id && t.IsDefault)
|
|
.Select(t => t.Rate)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
var taxTotal = Math.Round(subtotal * taxRate / 100m, 0);
|
|
var total = order.Total > 0 ? order.Total : subtotal + taxTotal;
|
|
|
|
var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, cancellationToken);
|
|
var meeziOrder = new Order
|
|
{
|
|
CafeId = cafe.Id,
|
|
OrderType = OrderType.Delivery,
|
|
Status = OrderStatus.Confirmed,
|
|
DisplayNumber = displayNumber,
|
|
SnappfoodOrderId = order.OrderId,
|
|
Subtotal = subtotal,
|
|
TaxTotal = taxTotal,
|
|
Total = total,
|
|
Items = orderItems
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(order.CustomerPhone))
|
|
{
|
|
var customer = await _db.Customers
|
|
.FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == order.CustomerPhone, cancellationToken);
|
|
if (customer is null)
|
|
{
|
|
customer = new Customer
|
|
{
|
|
CafeId = cafe.Id,
|
|
Name = order.CustomerName ?? "Snappfood",
|
|
Phone = order.CustomerPhone,
|
|
Group = CustomerGroup.New
|
|
};
|
|
_db.Customers.Add(customer);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
meeziOrder.CustomerId = customer.Id;
|
|
}
|
|
|
|
_db.Orders.Add(meeziOrder);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
var loaded = await _db.Orders
|
|
.Include(o => o.Items)
|
|
.ThenInclude(i => i.MenuItem)
|
|
.Include(o => o.Table)
|
|
.FirstAsync(o => o.Id == meeziOrder.Id, cancellationToken);
|
|
|
|
await _kdsNotifier.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), cancellationToken);
|
|
return (true, null);
|
|
}
|
|
|
|
private static string ComputeHmac(string body, string secret)
|
|
{
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
|
|
private async Task<int> AllocateDisplayNumberAsync(string cafeId, CancellationToken ct)
|
|
{
|
|
var max = await _db.Orders
|
|
.Where(o => o.CafeId == cafeId)
|
|
.MaxAsync(o => (int?)o.DisplayNumber, ct);
|
|
return (max ?? 0) + 1;
|
|
} private static LiveOrderDto MapLive(Order o) => new(
|
|
o.Id,
|
|
o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id),
|
|
o.Status,
|
|
o.Table?.Number,
|
|
o.OrderType,
|
|
o.Total,
|
|
o.CreatedAt,
|
|
o.Items.Select(i => new OrderItemDto(
|
|
i.Id,
|
|
i.MenuItemId,
|
|
i.MenuItem?.Name ?? "",
|
|
i.Quantity,
|
|
i.UnitPrice,
|
|
i.Notes,
|
|
i.IsVoided,
|
|
i.VoidedAt)).ToList(),
|
|
o.Source);
|
|
}
|