Files
meezi/src/Meezi.API/Services/SnappfoodWebhookService.cs
T
soroush.asadi 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
feat(realtime): global guest-order alert on the dashboard
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.
2026-06-03 02:42:29 +03:30

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);
}