Files
meezi/src/Meezi.API/Services/PublicService.cs
T
soroush.asadi 7d06f149d3 feat(plans): menu watermark on Free (removed by paid feature)
Guest QR menu shows a "ساخته‌شده با میزی" watermark under the menu unless the café's
plan has the `watermark_removed` feature (Starter+).

- PublicMenuDto gains ShowWatermark; PublicService computes it from
  IsFeatureEnabledForCafeAsync("watermark_removed") for both slug and branch menus.
- Guest menu renders the watermark footer when showWatermark.
- NoOpPlatformCatalogService test double (all features on) for the PublicService
  ctor; QrMenuTests updated.

86 tests pass; dashboard tsc clean.
2026-06-03 02:10:24 +03:30

434 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;
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;
}
/// <summary>Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it.</summary>
private async Task<bool> ShowWatermarkAsync(Cafe cafe, CancellationToken ct) =>
!await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct);
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,
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<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,
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);
}