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> DiscoverAsync( DiscoverFilterParams filters, CancellationToken cancellationToken = default); Task GetCafeAsync(string slug, CancellationToken cancellationToken = default); Task GetMenuAsync(string slug, CancellationToken cancellationToken = default); Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync( string slug, GuestCreateOrderRequest request, CancellationToken cancellationToken = default); Task TrackOrderAsync( string orderId, string trackingToken, CancellationToken cancellationToken = default); Task<(ReservationDto? Data, string? ErrorCode, string? Message)> CreateReservationAsync( string slug, CreateReservationRequest request, CancellationToken cancellationToken = default); Task 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; private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog; public PublicService( AppDbContext db, IOrderService orders, IReviewService reviews, IKdsNotifier kdsNotifier, IBranchMenuService branchMenu, IBranchIdentityService identity, IAbuseProtectionService abuse, IHttpContextAccessor http, Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog) { _db = db; _orders = orders; _reviews = reviews; _kdsNotifier = kdsNotifier; _branchMenu = branchMenu; _identity = identity; _abuse = abuse; _http = http; _catalog = catalog; } /// Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it. private async Task ShowWatermarkAsync(Cafe cafe, CancellationToken ct) => !await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct); public Task> DiscoverAsync( DiscoverFilterParams filters, CancellationToken cancellationToken = default) => _reviews.DiscoverAsync(filters, cancellationToken); public async Task 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 DeserializeStringList(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; try { return JsonSerializer.Deserialize>(json, _jsonOpts) ?? []; } catch { return []; } } private static WorkingHoursSchedule? DeserializeHours(string? json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize(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 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, await ShowWatermarkAsync(cafe, cancellationToken)); } 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 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 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, await ShowWatermarkAsync(cafe, cancellationToken)); } 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); }