feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user