425 lines
16 KiB
C#
425 lines
16 KiB
C#
|
|
using System.Text.Json;
|
||
|
|
using Meezi.API.Models.Menu;
|
||
|
|
using Meezi.API.Models.Orders;
|
||
|
|
using Meezi.API.Models.Public;
|
||
|
|
using Meezi.Core.Constants;
|
||
|
|
using Meezi.Core.Discover;
|
||
|
|
using Meezi.Core.Entities;
|
||
|
|
using Meezi.Core.Enums;
|
||
|
|
using Meezi.Infrastructure.Data;
|
||
|
|
using Meezi.API.Security;
|
||
|
|
using Meezi.Infrastructure.Discover;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
|
||
|
|
namespace Meezi.API.Services;
|
||
|
|
|
||
|
|
public interface IPublicService
|
||
|
|
{
|
||
|
|
Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||
|
|
DiscoverFilterParams filters,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<CafePublicDto?> GetCafeAsync(string slug, CancellationToken cancellationToken = default);
|
||
|
|
Task<PublicMenuDto?> GetMenuAsync(string slug, CancellationToken cancellationToken = default);
|
||
|
|
Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||
|
|
string slug,
|
||
|
|
GuestCreateOrderRequest request,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<OrderTrackDto?> TrackOrderAsync(
|
||
|
|
string orderId,
|
||
|
|
string trackingToken,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<(ReservationDto? Data, string? ErrorCode, string? Message)> CreateReservationAsync(
|
||
|
|
string slug,
|
||
|
|
CreateReservationRequest request,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<PublicMenuDto?> GetBranchMenuAsync(
|
||
|
|
string cafeId,
|
||
|
|
string branchId,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||
|
|
string cafeId,
|
||
|
|
string branchId,
|
||
|
|
PlaceGuestOrderRequest request,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
}
|
||
|
|
|
||
|
|
public class PublicService : IPublicService
|
||
|
|
{
|
||
|
|
private readonly AppDbContext _db;
|
||
|
|
private readonly IOrderService _orders;
|
||
|
|
private readonly IReviewService _reviews;
|
||
|
|
private readonly IKdsNotifier _kdsNotifier;
|
||
|
|
private readonly IBranchMenuService _branchMenu;
|
||
|
|
private readonly IBranchIdentityService _identity;
|
||
|
|
private readonly IAbuseProtectionService _abuse;
|
||
|
|
private readonly IHttpContextAccessor _http;
|
||
|
|
|
||
|
|
public PublicService(
|
||
|
|
AppDbContext db,
|
||
|
|
IOrderService orders,
|
||
|
|
IReviewService reviews,
|
||
|
|
IKdsNotifier kdsNotifier,
|
||
|
|
IBranchMenuService branchMenu,
|
||
|
|
IBranchIdentityService identity,
|
||
|
|
IAbuseProtectionService abuse,
|
||
|
|
IHttpContextAccessor http)
|
||
|
|
{
|
||
|
|
_db = db;
|
||
|
|
_orders = orders;
|
||
|
|
_reviews = reviews;
|
||
|
|
_kdsNotifier = kdsNotifier;
|
||
|
|
_branchMenu = branchMenu;
|
||
|
|
_identity = identity;
|
||
|
|
_abuse = abuse;
|
||
|
|
_http = http;
|
||
|
|
}
|
||
|
|
|
||
|
|
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||
|
|
DiscoverFilterParams filters,
|
||
|
|
CancellationToken cancellationToken = default) =>
|
||
|
|
_reviews.DiscoverAsync(filters, cancellationToken);
|
||
|
|
|
||
|
|
public async Task<CafePublicDto?> GetCafeAsync(string slug, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||
|
|
if (cafe is null) return null;
|
||
|
|
|
||
|
|
var (avg, count) = await _reviews.GetRatingSummaryAsync(cafe.Id, cancellationToken);
|
||
|
|
|
||
|
|
var profile = CafeDiscoverProfileSerializer.Deserialize(cafe.DiscoverProfileJson);
|
||
|
|
var badges = DiscoverBadgeMapping.ToDtos(cafe)
|
||
|
|
.Select(b => new CafeBadgePublicDto(b.Key, b.Label, b.Icon))
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
var gallery = DeserializeStringList(cafe.GalleryJson);
|
||
|
|
var hours = DeserializeHours(cafe.WorkingHoursJson);
|
||
|
|
|
||
|
|
return new CafePublicDto(
|
||
|
|
cafe.Id,
|
||
|
|
cafe.Name,
|
||
|
|
cafe.NameAr,
|
||
|
|
cafe.NameEn,
|
||
|
|
cafe.Slug,
|
||
|
|
cafe.City,
|
||
|
|
cafe.Address,
|
||
|
|
cafe.Phone,
|
||
|
|
cafe.LogoUrl,
|
||
|
|
cafe.CoverImageUrl,
|
||
|
|
cafe.Description,
|
||
|
|
cafe.IsVerified,
|
||
|
|
avg,
|
||
|
|
count,
|
||
|
|
CafeDiscoverProfileMapping.ToDto(profile),
|
||
|
|
badges,
|
||
|
|
gallery,
|
||
|
|
hours?.IsOpenNow() ?? false,
|
||
|
|
cafe.InstagramHandle,
|
||
|
|
cafe.WebsiteUrl,
|
||
|
|
ToHoursDto(hours));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||
|
|
{
|
||
|
|
PropertyNameCaseInsensitive = true,
|
||
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||
|
|
};
|
||
|
|
|
||
|
|
private static IReadOnlyList<string> DeserializeStringList(string? json)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(json)) return [];
|
||
|
|
try { return JsonSerializer.Deserialize<List<string>>(json, _jsonOpts) ?? []; }
|
||
|
|
catch { return []; }
|
||
|
|
}
|
||
|
|
|
||
|
|
private static WorkingHoursSchedule? DeserializeHours(string? json)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||
|
|
try { return JsonSerializer.Deserialize<WorkingHoursSchedule>(json, _jsonOpts); }
|
||
|
|
catch { return null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
private static WorkingHoursPublicDto? ToHoursDto(WorkingHoursSchedule? h)
|
||
|
|
{
|
||
|
|
if (h is null) return null;
|
||
|
|
DaySchedulePublicDto? Map(DaySchedule? d) =>
|
||
|
|
d is null ? null : new DaySchedulePublicDto(d.IsOpen, d.Open, d.Close);
|
||
|
|
return new WorkingHoursPublicDto(Map(h.Sat), Map(h.Sun), Map(h.Mon), Map(h.Tue), Map(h.Wed), Map(h.Thu), Map(h.Fri));
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PublicMenuDto?> GetMenuAsync(string slug, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||
|
|
if (cafe is null) return null;
|
||
|
|
|
||
|
|
var categories = await _db.MenuCategories
|
||
|
|
.Where(c => c.CafeId == cafe.Id && c.IsActive)
|
||
|
|
.OrderBy(c => c.SortOrder)
|
||
|
|
.ToListAsync(cancellationToken);
|
||
|
|
|
||
|
|
var items = await _db.MenuItems
|
||
|
|
.Include(i => i.Category)
|
||
|
|
.Where(i => i.CafeId == cafe.Id && i.IsAvailable)
|
||
|
|
.ToListAsync(cancellationToken);
|
||
|
|
|
||
|
|
var grouped = categories
|
||
|
|
.Select(cat => new PublicMenuCategoryDto(
|
||
|
|
cat.Id,
|
||
|
|
cat.Name,
|
||
|
|
cat.NameAr,
|
||
|
|
cat.NameEn,
|
||
|
|
cat.Icon,
|
||
|
|
cat.IconPresetId,
|
||
|
|
cat.IconStyle,
|
||
|
|
cat.ImageUrl,
|
||
|
|
items
|
||
|
|
.Where(i => i.CategoryId == cat.Id)
|
||
|
|
.Select(i => new PublicMenuItemDto(
|
||
|
|
i.Id,
|
||
|
|
i.CategoryId,
|
||
|
|
i.Name,
|
||
|
|
i.NameAr,
|
||
|
|
i.NameEn,
|
||
|
|
i.Description,
|
||
|
|
i.Price,
|
||
|
|
i.DiscountPercent > 0 ? i.DiscountPercent : cat.DiscountPercent,
|
||
|
|
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||
|
|
i.VideoUrl,
|
||
|
|
i.Model3dUrl,
|
||
|
|
i.IsAvailable))
|
||
|
|
.ToList()))
|
||
|
|
.Where(c => c.Items.Count > 0)
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||
|
|
string slug,
|
||
|
|
GuestCreateOrderRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||
|
|
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||
|
|
|
||
|
|
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: true, cancellationToken);
|
||
|
|
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||
|
|
|
||
|
|
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||
|
|
if (maxOrders != int.MaxValue)
|
||
|
|
{
|
||
|
|
var todayStart = DateTime.UtcNow.Date;
|
||
|
|
var count = await _db.Orders.CountAsync(
|
||
|
|
o => o.CafeId == cafe.Id && o.CreatedAt >= todayStart,
|
||
|
|
cancellationToken);
|
||
|
|
if (count >= maxOrders)
|
||
|
|
return (null, "PLAN_LIMIT_REACHED", "This cafe has reached its daily order limit.");
|
||
|
|
}
|
||
|
|
|
||
|
|
string? couponId = null;
|
||
|
|
if (!string.IsNullOrWhiteSpace(request.CouponCode))
|
||
|
|
{
|
||
|
|
var coupon = await _db.Coupons.FirstOrDefaultAsync(
|
||
|
|
c => c.CafeId == cafe.Id && c.Code == request.CouponCode && c.IsActive,
|
||
|
|
cancellationToken);
|
||
|
|
couponId = coupon?.Id;
|
||
|
|
}
|
||
|
|
|
||
|
|
var order = await _orders.CreateGuestOrderAsync(
|
||
|
|
cafe.Id,
|
||
|
|
new CreateOrderRequest(
|
||
|
|
request.OrderType,
|
||
|
|
null,
|
||
|
|
request.TableId,
|
||
|
|
null,
|
||
|
|
request.GuestName,
|
||
|
|
request.GuestPhone,
|
||
|
|
null,
|
||
|
|
couponId,
|
||
|
|
request.Items),
|
||
|
|
request.GuestPhone,
|
||
|
|
request.GuestName,
|
||
|
|
cancellationToken);
|
||
|
|
|
||
|
|
if (order is null)
|
||
|
|
return (null, "VALIDATION_ERROR", "Could not place order. Check menu items and table.");
|
||
|
|
|
||
|
|
return (new GuestOrderPlacedDto(order.Id, order.Status, order.Total, order.TableNumber), null, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<OrderTrackDto?> TrackOrderAsync(
|
||
|
|
string orderId,
|
||
|
|
string trackingToken,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var order = await _db.Orders
|
||
|
|
.Include(o => o.Items)
|
||
|
|
.ThenInclude(i => i.MenuItem)
|
||
|
|
.Include(o => o.Table)
|
||
|
|
.FirstOrDefaultAsync(
|
||
|
|
o => o.Id == orderId && o.GuestTrackingToken == trackingToken,
|
||
|
|
cancellationToken);
|
||
|
|
|
||
|
|
if (order is null) return null;
|
||
|
|
|
||
|
|
return OrderTrackingHelper.BuildTrackDto(order);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<(ReservationDto? Data, string? ErrorCode, string? Message)> CreateReservationAsync(
|
||
|
|
string slug,
|
||
|
|
CreateReservationRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||
|
|
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||
|
|
|
||
|
|
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: false, cancellationToken);
|
||
|
|
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||
|
|
|
||
|
|
var entity = new TableReservation
|
||
|
|
{
|
||
|
|
CafeId = cafe.Id,
|
||
|
|
TableId = request.TableId,
|
||
|
|
GuestName = request.GuestName,
|
||
|
|
GuestPhone = request.GuestPhone,
|
||
|
|
Date = request.Date,
|
||
|
|
Time = request.Time,
|
||
|
|
PartySize = request.PartySize,
|
||
|
|
Notes = request.Notes,
|
||
|
|
Status = ReservationStatus.Pending
|
||
|
|
};
|
||
|
|
|
||
|
|
_db.TableReservations.Add(entity);
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
|
||
|
|
if (!string.IsNullOrEmpty(entity.TableId))
|
||
|
|
await _kdsNotifier.NotifyTableStatusChangedAsync(cafe.Id, cancellationToken);
|
||
|
|
|
||
|
|
var loaded = await _db.TableReservations
|
||
|
|
.Include(r => r.Table)
|
||
|
|
.FirstAsync(r => r.Id == entity.Id, cancellationToken);
|
||
|
|
|
||
|
|
return (ToReservationDto(loaded), null, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PublicMenuDto?> GetBranchMenuAsync(
|
||
|
|
string cafeId,
|
||
|
|
string branchId,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||
|
|
if (cafe is null) return null;
|
||
|
|
|
||
|
|
var branchMenu = await _branchMenu.GetBranchMenuAsync(
|
||
|
|
cafeId,
|
||
|
|
branchId,
|
||
|
|
includeUnavailable: false,
|
||
|
|
cancellationToken);
|
||
|
|
if (branchMenu is null) return null;
|
||
|
|
|
||
|
|
var categories = await _db.MenuCategories
|
||
|
|
.Where(c => c.CafeId == cafeId && c.IsActive)
|
||
|
|
.OrderBy(c => c.SortOrder)
|
||
|
|
.ToListAsync(cancellationToken);
|
||
|
|
|
||
|
|
var categoryById = categories.ToDictionary(c => c.Id);
|
||
|
|
var grouped = branchMenu
|
||
|
|
.Where(i => i.IsAvailable)
|
||
|
|
.GroupBy(i => i.CategoryId)
|
||
|
|
.Select(g =>
|
||
|
|
{
|
||
|
|
categoryById.TryGetValue(g.Key, out var cat);
|
||
|
|
return new PublicMenuCategoryDto(
|
||
|
|
g.Key,
|
||
|
|
cat?.Name ?? "",
|
||
|
|
cat?.NameAr,
|
||
|
|
cat?.NameEn,
|
||
|
|
cat?.Icon,
|
||
|
|
cat?.IconPresetId,
|
||
|
|
cat?.IconStyle,
|
||
|
|
cat?.ImageUrl,
|
||
|
|
g.Select(i => new PublicMenuItemDto(
|
||
|
|
i.Id,
|
||
|
|
i.CategoryId,
|
||
|
|
i.Name,
|
||
|
|
i.NameAr,
|
||
|
|
i.NameEn,
|
||
|
|
i.Description,
|
||
|
|
i.EffectivePrice,
|
||
|
|
i.DiscountPercent,
|
||
|
|
MenuItemImageDefaults.IsUsableImageUrl(i.ImageUrl)
|
||
|
|
? i.ImageUrl!
|
||
|
|
: MenuItemImageDefaults.ResolveImageUrl(i.Id, i.CategoryId, null),
|
||
|
|
i.VideoUrl,
|
||
|
|
i.Model3dUrl,
|
||
|
|
true)).ToList());
|
||
|
|
})
|
||
|
|
.Where(c => c.Items.Count > 0)
|
||
|
|
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||
|
|
string cafeId,
|
||
|
|
string branchId,
|
||
|
|
PlaceGuestOrderRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||
|
|
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||
|
|
|
||
|
|
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: true, cancellationToken);
|
||
|
|
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||
|
|
|
||
|
|
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||
|
|
if (maxOrders != int.MaxValue)
|
||
|
|
{
|
||
|
|
var todayStart = DateTime.UtcNow.Date;
|
||
|
|
var count = await _db.Orders.CountAsync(
|
||
|
|
o => o.CafeId == cafe.Id && o.CreatedAt >= todayStart,
|
||
|
|
cancellationToken);
|
||
|
|
if (count >= maxOrders)
|
||
|
|
return (null, "PLAN_LIMIT_REACHED", "This cafe has reached its daily order limit.");
|
||
|
|
}
|
||
|
|
|
||
|
|
return await _orders.PlaceBranchGuestOrderAsync(cafeId, branchId, request, cancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<(bool Ok, string? ErrorCode, string? Message)> GuardPublicWriteAsync(
|
||
|
|
Cafe cafe,
|
||
|
|
string? captchaToken,
|
||
|
|
bool guestOrder,
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
var availability = PublicCafeGuard.EnsureAcceptingPublicTraffic(cafe);
|
||
|
|
if (!availability.Ok)
|
||
|
|
return (false, availability.ErrorCode, availability.Message);
|
||
|
|
|
||
|
|
var ctx = _http.HttpContext;
|
||
|
|
if (ctx is null)
|
||
|
|
return (true, null, null);
|
||
|
|
|
||
|
|
var ip = ClientIpResolver.GetClientIp(ctx);
|
||
|
|
var writeCheck = await _abuse.CheckPublicWriteByIpAsync(ip, cancellationToken);
|
||
|
|
if (!writeCheck.Allowed)
|
||
|
|
return (false, writeCheck.ErrorCode, writeCheck.Message);
|
||
|
|
|
||
|
|
if (guestOrder)
|
||
|
|
{
|
||
|
|
var orderCheck = await _abuse.CheckGuestOrderAsync(cafe.Id, ip, cancellationToken);
|
||
|
|
if (!orderCheck.Allowed)
|
||
|
|
return (false, orderCheck.ErrorCode, orderCheck.Message);
|
||
|
|
}
|
||
|
|
|
||
|
|
var captcha = await _abuse.VerifyCaptchaAsync(captchaToken, cancellationToken);
|
||
|
|
if (!captcha.Ok)
|
||
|
|
return (false, captcha.ErrorCode, captcha.Message);
|
||
|
|
|
||
|
|
return (true, null, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static ReservationDto ToReservationDto(TableReservation r) =>
|
||
|
|
ReservationService.Map(r);
|
||
|
|
}
|