Files
meezi/src/Meezi.API/Controllers/PublicController.cs
T
soroush.asadi ef15fd6247 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>
2026-05-27 21:33:48 +03:30

371 lines
14 KiB
C#

using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Meezi.API.Models.Public;
using Meezi.API.Models.Queue;
using Meezi.API.Security;
using Meezi.API.Services;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/public")]
public class PublicController : ControllerBase
{
private readonly IPublicService _public;
private readonly IReviewService _reviews;
private readonly IValidator<GuestCreateOrderRequest> _orderValidator;
private readonly IValidator<PlaceGuestOrderRequest> _qrOrderValidator;
private readonly IValidator<CreateReservationRequest> _reservationValidator;
private readonly IValidator<CreateCafeReviewRequest> _reviewValidator;
private readonly IAbuseProtectionService _abuse;
private readonly AbuseProtectionOptions _securityOptions;
public PublicController(
IPublicService publicService,
IReviewService reviews,
IValidator<GuestCreateOrderRequest> orderValidator,
IValidator<PlaceGuestOrderRequest> qrOrderValidator,
IValidator<CreateReservationRequest> reservationValidator,
IValidator<CreateCafeReviewRequest> reviewValidator,
IAbuseProtectionService abuse,
IOptions<AbuseProtectionOptions> securityOptions)
{
_public = publicService;
_reviews = reviews;
_orderValidator = orderValidator;
_qrOrderValidator = qrOrderValidator;
_reservationValidator = reservationValidator;
_reviewValidator = reviewValidator;
_abuse = abuse;
_securityOptions = securityOptions.Value;
}
[HttpGet("security-config")]
[EnableRateLimiting("public-read")]
public IActionResult GetSecurityConfig()
{
var dto = new PublicSecurityConfigDto(
_securityOptions.Enabled,
_abuse.CaptchaSiteKey,
_securityOptions.RequireCaptchaOnPublicWrites && _abuse.IsCaptchaConfigured);
return Ok(new ApiResponse<PublicSecurityConfigDto>(true, dto));
}
[HttpGet("discover")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> Discover(
[FromQuery] string? city,
[FromQuery] string? q,
[FromQuery] double? minRating,
[FromQuery] string? sort,
[FromQuery] string? themes,
[FromQuery] string? vibes,
[FromQuery] string? occasions,
[FromQuery] string? spaceFeatures,
[FromQuery] string? noise,
[FromQuery] string? priceTier,
[FromQuery] string? size,
[FromQuery] bool requireProfile = true,
[FromQuery] bool openNow = false,
CancellationToken ct = default)
{
var filters = DiscoverFilterParams.FromQuery(
city, q, minRating, sort, themes, vibes, occasions, spaceFeatures,
noise, priceTier, size, requireProfile, openNow);
var data = await _public.DiscoverAsync(filters, ct);
return Ok(new ApiResponse<IReadOnlyList<CafeDiscoverDto>>(true, data));
}
[HttpGet("cafes/{slug}/reviews")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetReviews(
string slug,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var data = await _reviews.GetReviewsAsync(cafe.Id, page, pageSize, publicOnly: true, ct);
return Ok(new ApiResponse<IReadOnlyList<CafeReviewDto>>(true, data));
}
[HttpPost("cafes/{slug}/reviews")]
public async Task<IActionResult> CreateReview(
string slug,
[FromBody] CreateCafeReviewRequest request,
CancellationToken ct)
{
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (data, code, message) = await _reviews.CreateReviewAsync(cafe.Id, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
}
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
[HttpPost("cafes/{slug}/reviews/upload")]
[RequestSizeLimit(20 * 1024 * 1024)]
public async Task<IActionResult> CreateReviewWithPhotos(
string slug,
[FromForm] string authorName,
[FromForm] int rating,
[FromForm] string? comment,
[FromForm] string? authorPhone,
[FromForm] string? captchaToken,
[FromForm] List<IFormFile>? photos,
CancellationToken ct)
{
var request = new CreateCafeReviewRequest(
authorName?.Trim() ?? "",
authorPhone?.Trim(),
rating,
comment?.Trim(),
captchaToken);
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (data, code, message) = await _reviews.CreateReviewWithPhotosAsync(
cafe.Id,
request,
photos ?? [],
ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
}
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
[HttpGet("badge-catalog")]
[EnableRateLimiting("public-read")]
public IActionResult BadgeCatalog()
{
var data = Core.Discover.CafeBadgeCatalog.All
.Select(b => new CafeBadgePublicDto(b.Key, b.LabelFa, b.Icon))
.ToList();
return Ok(new ApiResponse<IReadOnlyList<CafeBadgePublicDto>>(true, data));
}
[HttpGet("cafes/{slug}")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetCafe(string slug, CancellationToken ct)
{
var data = await _public.GetCafeAsync(slug, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<CafePublicDto>(true, data));
}
[HttpGet("cafes/{slug}/menu")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetMenu(string slug, CancellationToken ct)
{
var data = await _public.GetMenuAsync(slug, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<PublicMenuDto>(true, data));
}
[HttpPost("cafes/{slug}/orders")]
public async Task<IActionResult> PlaceOrder(
string slug,
[FromBody] GuestCreateOrderRequest request,
CancellationToken ct)
{
var validation = await _orderValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.PlaceOrderAsync(slug, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<GuestOrderPlacedDto>(true, data));
}
[HttpGet("orders/{orderId}/track")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> TrackOrder(
string orderId,
[FromQuery] string token,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Tracking token is required.")));
var data = await _public.TrackOrderAsync(orderId, token, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Order not found.")));
return Ok(new ApiResponse<OrderTrackDto>(true, data));
}
[HttpGet("{cafeId}/branches/{branchId}/menu")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetBranchMenu(
string cafeId,
string branchId,
CancellationToken ct)
{
var data = await _public.GetBranchMenuAsync(cafeId, branchId, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch or menu not found.")));
return Ok(new ApiResponse<PublicMenuDto>(true, data));
}
[HttpGet("{cafeId}/branches/{branchId}/identity")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetBranchIdentity(
string cafeId,
string branchId,
[FromServices] IBranchIdentityService identity,
CancellationToken ct)
{
var data = await identity.GetEffectiveIdentityAsync(cafeId, branchId, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch not found.")));
return Ok(new ApiResponse<BranchEffectiveIdentityDto>(true, data));
}
[HttpPost("{cafeId}/branches/{branchId}/orders")]
public async Task<IActionResult> PlaceBranchGuestOrder(
string cafeId,
string branchId,
[FromBody] PlaceGuestOrderRequest request,
CancellationToken ct)
{
var validation = await _qrOrderValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.PlaceBranchGuestOrderAsync(cafeId, branchId, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<GuestQrOrderPlacedDto>(true, data));
}
[HttpPost("cafes/{slug}/reservations")]
public async Task<IActionResult> CreateReservation(
string slug,
[FromBody] CreateReservationRequest request,
CancellationToken ct)
{
var validation = await _reservationValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.CreateReservationAsync(slug, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<ReservationDto>(true, data));
}
[HttpPost("cafes/{slug}/queue/tickets")]
[EnableRateLimiting("public-write")]
public async Task<IActionResult> IssuePublicQueueTicket(
string slug,
[FromBody] IssueQueueTicketRequest request,
[FromServices] IQueueService queue,
[FromServices] AppDbContext db,
CancellationToken ct)
{
var cafe = await db.Cafes.AsNoTracking()
.FirstOrDefaultAsync(c => c.Slug == slug && c.DeletedAt == null, ct);
if (cafe is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (ticket, code, message) = await queue.IssuePublicAsync(cafe.Id, cafe.PlanTier, request, ct);
if (ticket is null)
{
var status = code == "PLAN_LIMIT_REACHED" ? 403 : 400;
return StatusCode(status, new ApiResponse<object>(false, null,
new ApiError(code ?? "ERROR", message ?? "Could not issue ticket.")));
}
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
}
[HttpPost("{cafeId}/tables/{tableId}/call-waiter")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> CallWaiter(
string cafeId,
string tableId,
[FromServices] AppDbContext db,
[FromServices] IOrderNotificationService notifications,
[FromServices] IMemoryCache cache,
CancellationToken ct)
{
var cooldownKey = $"call-waiter:{cafeId}:{tableId}";
if (cache.TryGetValue(cooldownKey, out _))
return StatusCode(StatusCodes.Status429TooManyRequests,
new ApiResponse<object>(false, null, new ApiError("RATE_LIMITED", "Please wait 60 seconds before calling again.")));
var table = await db.Tables.AsNoTracking()
.Where(t => t.Id == tableId && t.CafeId == cafeId && t.DeletedAt == null && t.IsActive)
.Select(t => new { t.Id, t.Number })
.FirstOrDefaultAsync(ct);
if (table is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Table not found.")));
cache.Set(cooldownKey, true, TimeSpan.FromSeconds(60));
await notifications.NotifyCallWaiterAsync(cafeId, tableId, table.Number, ct);
return Ok(new ApiResponse<object>(true, null));
}
}