5e980cdfc0
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m15s
• PlanLimits: add MaxMenuCategories (Free→3), MaxMenuItems (Free→30), CanAccessCrm and CanAccessStatistics (Pro+ only) • MenuController: enforce category/item limits before create (403 + PLAN_LIMIT_REACHED) • Cafe entity + EF migration: Latitude/Longitude (double?, nullable) • CafeSettingsController: PATCH accepts lat/lng with range validation • PublicController: GET /api/public/map-markers (marketing SVG map feed) and GET /api/public/nearby (Koja nearby-cafés with Haversine sort) • Dashboard settings: location card with OSM iframe preview + Neshan link • Website homepage: IranMapSection — stylised SVG silhouette with SMIL-animated blinking dots at real café coordinates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
468 lines
18 KiB
C#
468 lines
18 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all cafés that have a known location (Latitude/Longitude set).
|
|
/// Used by the marketing website SVG map to render blinking dots.
|
|
/// </summary>
|
|
[HttpGet("map-markers")]
|
|
[EnableRateLimiting("public-read")]
|
|
public async Task<IActionResult> GetMapMarkers(
|
|
[FromServices] AppDbContext db,
|
|
CancellationToken ct)
|
|
{
|
|
var markers = await db.Cafes
|
|
.AsNoTracking()
|
|
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
|
|
.Select(c => new
|
|
{
|
|
c.Id,
|
|
c.Name,
|
|
c.Slug,
|
|
c.City,
|
|
c.Latitude,
|
|
c.Longitude,
|
|
c.LogoUrl
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
return Ok(new ApiResponse<object>(true, markers));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns cafés near a given coordinate, sorted by distance ascending.
|
|
/// Used by Koja guest page to show "nearby cafés" section.
|
|
/// At most <paramref name="limit"/> results (default 5, max 20).
|
|
/// </summary>
|
|
[HttpGet("nearby")]
|
|
[EnableRateLimiting("public-read")]
|
|
public async Task<IActionResult> GetNearbyCafes(
|
|
[FromQuery] double lat,
|
|
[FromQuery] double lng,
|
|
[FromQuery] string? excludeSlug,
|
|
[FromQuery] int limit,
|
|
[FromServices] AppDbContext db,
|
|
CancellationToken ct)
|
|
{
|
|
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
|
|
|
|
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
|
|
var cafes = await db.Cafes
|
|
.AsNoTracking()
|
|
.Where(c => c.DeletedAt == null
|
|
&& c.Latitude != null
|
|
&& c.Longitude != null
|
|
&& (excludeSlug == null || c.Slug != excludeSlug))
|
|
.Select(c => new
|
|
{
|
|
c.Id,
|
|
c.Name,
|
|
c.Slug,
|
|
c.City,
|
|
c.Latitude,
|
|
c.Longitude,
|
|
c.LogoUrl,
|
|
c.CoverImageUrl
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
static double ToRad(double deg) => deg * Math.PI / 180.0;
|
|
static double Haversine(double lat1, double lon1, double lat2, double lon2)
|
|
{
|
|
const double R = 6371; // km
|
|
var dLat = ToRad(lat2 - lat1);
|
|
var dLon = ToRad(lon2 - lon1);
|
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
|
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
|
|
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
|
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
|
}
|
|
|
|
var nearby = cafes
|
|
.Select(c => new
|
|
{
|
|
c.Id,
|
|
c.Name,
|
|
c.Slug,
|
|
c.City,
|
|
c.Latitude,
|
|
c.Longitude,
|
|
c.LogoUrl,
|
|
c.CoverImageUrl,
|
|
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
|
|
})
|
|
.OrderBy(c => c.DistanceKm)
|
|
.Take(limit)
|
|
.ToList();
|
|
|
|
return Ok(new ApiResponse<object>(true, nearby));
|
|
}
|
|
}
|