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,169 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/menu")]
|
||||
public class MenuController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IMenuService _menuService;
|
||||
private readonly IMenuAi3dGenerationService _menuAi3d;
|
||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||
|
||||
public MenuController(
|
||||
IMenuService menuService,
|
||||
IMenuAi3dGenerationService menuAi3d,
|
||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||
IValidator<CreateMenuItemRequest> createItemValidator)
|
||||
{
|
||||
_menuService = menuService;
|
||||
_menuAi3d = menuAi3d;
|
||||
_createCategoryValidator = createCategoryValidator;
|
||||
_createItemValidator = createItemValidator;
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
public async Task<IActionResult> GetCategories(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.GetCategoriesAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<MenuCategoryDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("categories")]
|
||||
public async Task<IActionResult> CreateCategory(
|
||||
string cafeId,
|
||||
[FromBody] CreateMenuCategoryRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("categories/{id}")]
|
||||
public async Task<IActionResult> UpdateCategory(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuCategoryRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("categories/{id}")]
|
||||
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
|
||||
[HttpGet("items")]
|
||||
public async Task<IActionResult> GetItems(
|
||||
string cafeId,
|
||||
[FromQuery] string? categoryId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.GetItemsAsync(cafeId, categoryId, cancellationToken);
|
||||
return Ok(new ApiResponse<IReadOnlyList<MenuItemDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("items")]
|
||||
public async Task<IActionResult> CreateItem(
|
||||
string cafeId,
|
||||
[FromBody] CreateMenuItemRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
||||
if (data is null) return NotFoundError("Category not found.");
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("items/{id}")]
|
||||
public async Task<IActionResult> UpdateItem(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuItemRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPatch("items/{id}/availability")]
|
||||
public async Task<IActionResult> SetAvailability(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] UpdateMenuItemAvailabilityRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("ai-3d/usage")]
|
||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var data = await _menuAi3d.GetUsageAsync(cafeId, tier, cancellationToken);
|
||||
return Ok(new ApiResponse<MenuAi3dUsageDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("items/{id}/ai-3d")]
|
||||
public async Task<IActionResult> GenerateAi3d(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
||||
if (code is not null)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
"PLAN_FEATURE_DISABLED" => StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
"PLAN_LIMIT_REACHED" => StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message ?? "")))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<MenuAi3dGenerateResultDto>(true, data));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user