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 _orderValidator; private readonly IValidator _qrOrderValidator; private readonly IValidator _reservationValidator; private readonly IValidator _reviewValidator; private readonly IAbuseProtectionService _abuse; private readonly AbuseProtectionOptions _securityOptions; public PublicController( IPublicService publicService, IReviewService reviews, IValidator orderValidator, IValidator qrOrderValidator, IValidator reservationValidator, IValidator reviewValidator, IAbuseProtectionService abuse, IOptions 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(true, dto)); } [HttpGet("discover")] [EnableRateLimiting("public-read")] public async Task 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>(true, data)); } [HttpGet("cafes/{slug}/reviews")] [EnableRateLimiting("public-read")] public async Task 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(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>(true, data)); } [HttpPost("cafes/{slug}/reviews")] public async Task 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(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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review."))); } return Ok(new ApiResponse(true, data)); } [HttpPost("cafes/{slug}/reviews/upload")] [RequestSizeLimit(20 * 1024 * 1024)] public async Task CreateReviewWithPhotos( string slug, [FromForm] string authorName, [FromForm] int rating, [FromForm] string? comment, [FromForm] string? authorPhone, [FromForm] string? captchaToken, [FromForm] List? 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(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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review."))); } return Ok(new ApiResponse(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>(true, data)); } [HttpGet("cafes/{slug}")] [EnableRateLimiting("public-read")] public async Task GetCafe(string slug, CancellationToken ct) { var data = await _public.GetCafeAsync(slug, ct); if (data is null) return NotFound(new ApiResponse(false, null, new ApiError("NOT_FOUND", "Cafe not found."))); return Ok(new ApiResponse(true, data)); } [HttpGet("cafes/{slug}/menu")] [EnableRateLimiting("public-read")] public async Task GetMenu(string slug, CancellationToken ct) { var data = await _public.GetMenuAsync(slug, ct); if (data is null) return NotFound(new ApiResponse(false, null, new ApiError("NOT_FOUND", "Cafe not found."))); return Ok(new ApiResponse(true, data)); } [HttpPost("cafes/{slug}/orders")] public async Task 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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Failed."))); } return Ok(new ApiResponse(true, data)); } [HttpGet("orders/{orderId}/track")] [EnableRateLimiting("public-read")] public async Task TrackOrder( string orderId, [FromQuery] string token, CancellationToken ct) { if (string.IsNullOrWhiteSpace(token)) return BadRequest(new ApiResponse(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(false, null, new ApiError("NOT_FOUND", "Order not found."))); return Ok(new ApiResponse(true, data)); } [HttpGet("{cafeId}/branches/{branchId}/menu")] [EnableRateLimiting("public-read")] public async Task GetBranchMenu( string cafeId, string branchId, CancellationToken ct) { var data = await _public.GetBranchMenuAsync(cafeId, branchId, ct); if (data is null) return NotFound(new ApiResponse(false, null, new ApiError("NOT_FOUND", "Branch or menu not found."))); return Ok(new ApiResponse(true, data)); } [HttpGet("{cafeId}/branches/{branchId}/identity")] [EnableRateLimiting("public-read")] public async Task 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(false, null, new ApiError("NOT_FOUND", "Branch not found."))); return Ok(new ApiResponse(true, data)); } [HttpPost("{cafeId}/branches/{branchId}/orders")] public async Task 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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Failed."))); } return Ok(new ApiResponse(true, data)); } [HttpPost("cafes/{slug}/reservations")] public async Task 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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Failed."))); } return Ok(new ApiResponse(true, data)); } [HttpPost("cafes/{slug}/queue/tickets")] [EnableRateLimiting("public-write")] public async Task 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(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(false, null, new ApiError(code ?? "ERROR", message ?? "Could not issue ticket."))); } return Ok(new ApiResponse(true, ticket)); } [HttpPost("{cafeId}/tables/{tableId}/call-waiter")] [EnableRateLimiting("public-read")] public async Task 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(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(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(true, null)); } }