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,229 @@
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMenuService
|
||||
{
|
||||
Task<IReadOnlyList<MenuCategoryDto>> GetCategoriesAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<MenuCategoryDto?> CreateCategoryAsync(string cafeId, CreateMenuCategoryRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuCategoryDto?> UpdateCategoryAsync(string cafeId, string id, UpdateMenuCategoryRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteCategoryAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<MenuItemDto>> GetItemsAsync(string cafeId, string? categoryId, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MenuService : IMenuService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public MenuService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MenuCategoryDto>> GetCategoriesAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _db.MenuCategories
|
||||
.Where(c => c.CafeId == cafeId)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => ToCategoryDto(c))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MenuCategoryDto?> CreateCategoryAsync(string cafeId, CreateMenuCategoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = new MenuCategory
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Name = request.Name,
|
||||
NameAr = request.NameAr,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
TaxId = request.TaxId,
|
||||
DiscountPercent = request.DiscountPercent,
|
||||
Icon = NormalizeOptionalText(request.Icon),
|
||||
IconPresetId = NormalizeIconPreset(request.IconPresetId),
|
||||
IconStyle = NormalizeIconStyle(request.IconStyle)
|
||||
?? (NormalizeIconPreset(request.IconPresetId) is not null
|
||||
? CategoryIconPresets.IconStyle.Flat
|
||||
: null),
|
||||
ImageUrl = NormalizeOptionalText(request.ImageUrl),
|
||||
IsActive = request.IsActive,
|
||||
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId)
|
||||
? null
|
||||
: request.KitchenStationId
|
||||
};
|
||||
|
||||
_db.MenuCategories.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToCategoryDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuCategoryDto?> UpdateCategoryAsync(string cafeId, string id, UpdateMenuCategoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuCategories.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.NameAr is not null) entity.NameAr = request.NameAr;
|
||||
if (request.NameEn is not null) entity.NameEn = request.NameEn;
|
||||
if (request.SortOrder.HasValue) entity.SortOrder = request.SortOrder.Value;
|
||||
if (request.TaxId is not null) entity.TaxId = request.TaxId;
|
||||
if (request.DiscountPercent.HasValue) entity.DiscountPercent = request.DiscountPercent.Value;
|
||||
if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value;
|
||||
if (request.Icon is not null)
|
||||
entity.Icon = NormalizeOptionalText(request.Icon);
|
||||
if (request.IconPresetId is not null)
|
||||
entity.IconPresetId = NormalizeIconPreset(request.IconPresetId);
|
||||
if (request.IconStyle is not null)
|
||||
entity.IconStyle = NormalizeIconStyle(request.IconStyle);
|
||||
if (request.ImageUrl is not null)
|
||||
entity.ImageUrl = NormalizeOptionalText(request.ImageUrl);
|
||||
if (request.KitchenStationId is not null)
|
||||
entity.KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId)
|
||||
? null
|
||||
: request.KitchenStationId;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToCategoryDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCategoryAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuCategories.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MenuItemDto>> GetItemsAsync(string cafeId, string? categoryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.MenuItems.Where(i => i.CafeId == cafeId);
|
||||
if (!string.IsNullOrEmpty(categoryId))
|
||||
query = query.Where(i => i.CategoryId == categoryId);
|
||||
|
||||
var items = await query.Include(i => i.Category).OrderBy(i => i.Name).ToListAsync(cancellationToken);
|
||||
return items.Select(ToItemDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var category = await _db.MenuCategories
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.CafeId == cafeId, cancellationToken);
|
||||
if (category is null) return null;
|
||||
|
||||
var imageUrl = string.IsNullOrWhiteSpace(request.ImageUrl)
|
||||
? MenuItemImageDefaults.GetDefaultImageUrl(
|
||||
MenuItemImageDefaults.InferKind(request.CategoryId, category.Name))
|
||||
: request.ImageUrl;
|
||||
|
||||
var entity = new MenuItem
|
||||
{
|
||||
CafeId = cafeId,
|
||||
CategoryId = request.CategoryId,
|
||||
Name = request.Name,
|
||||
NameAr = request.NameAr,
|
||||
NameEn = request.NameEn,
|
||||
Description = request.Description,
|
||||
Price = request.Price,
|
||||
DiscountPercent = request.DiscountPercent,
|
||||
ImageUrl = imageUrl,
|
||||
VideoUrl = request.VideoUrl,
|
||||
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
|
||||
IsAvailable = request.IsAvailable
|
||||
};
|
||||
|
||||
_db.MenuItems.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems
|
||||
.Include(i => i.Category)
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.CategoryId is not null)
|
||||
{
|
||||
var categoryExists = await _db.MenuCategories.AnyAsync(c => c.Id == request.CategoryId && c.CafeId == cafeId, cancellationToken);
|
||||
if (!categoryExists) return null;
|
||||
entity.CategoryId = request.CategoryId;
|
||||
}
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.NameAr is not null) entity.NameAr = request.NameAr;
|
||||
if (request.NameEn is not null) entity.NameEn = request.NameEn;
|
||||
if (request.Description is not null) entity.Description = request.Description;
|
||||
if (request.Price.HasValue) entity.Price = request.Price.Value;
|
||||
if (request.DiscountPercent.HasValue) entity.DiscountPercent = request.DiscountPercent.Value;
|
||||
if (request.ImageUrl is not null)
|
||||
{
|
||||
entity.ImageUrl = string.IsNullOrWhiteSpace(request.ImageUrl)
|
||||
? MenuItemImageDefaults.ResolveImageUrl(entity.Id, entity.CategoryId, entity.Category?.Name)
|
||||
: request.ImageUrl;
|
||||
}
|
||||
if (request.VideoUrl is not null)
|
||||
entity.VideoUrl = string.IsNullOrWhiteSpace(request.VideoUrl) ? null : request.VideoUrl;
|
||||
if (request.Model3dUrl is not null)
|
||||
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
|
||||
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.IsAvailable = isAvailable;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? NormalizeIconPreset(string? value)
|
||||
{
|
||||
var normalized = CategoryIconPresets.NormalizePreset(value);
|
||||
if (normalized is null) return null;
|
||||
return CategoryIconPresets.IsValidPreset(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeIconStyle(string? value)
|
||||
{
|
||||
var normalized = CategoryIconPresets.NormalizeStyle(value);
|
||||
if (normalized is null) return null;
|
||||
return CategoryIconPresets.IsValidStyle(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
private static MenuCategoryDto ToCategoryDto(MenuCategory c) => new(
|
||||
c.Id, c.Name, c.NameAr, c.NameEn, c.SortOrder, c.TaxId, c.DiscountPercent,
|
||||
c.Icon, c.IconPresetId, c.IconStyle, c.ImageUrl, c.IsActive, c.KitchenStationId);
|
||||
|
||||
private static MenuItemDto ToItemDto(MenuItem i) => new(
|
||||
i.Id,
|
||||
i.CategoryId,
|
||||
i.Name,
|
||||
i.NameAr,
|
||||
i.NameEn,
|
||||
i.Description,
|
||||
i.Price,
|
||||
i.DiscountPercent,
|
||||
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||||
i.VideoUrl,
|
||||
i.Model3dUrl,
|
||||
i.IsAvailable);
|
||||
}
|
||||
Reference in New Issue
Block a user