Files
meezi/src/Meezi.API/Controllers/CafeSettingsController.cs
T
soroush.asadi 2487f9e30f
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m51s
feat(plans): Stage 3b — DB-driven gates for reviews/styling/limits
Make more plan rules read the admin-editable catalog instead of hardcoded values:
- Review reply gated by the `review_reply` feature (Starter+) — 403 if not in plan.
- Custom menu styling gated by `custom_menu_styling` (Starter+): only blocks an
  actual theme change, so a normal settings save re-sending the current theme is fine.
- Menu categories/items limits now read catalog.GetLimitsAsync (Free categories
  editable; message no longer hardcodes a number).
- Terminals limit reads the catalog (enforcement in TerminalRegistryService +
  the displayed max in TerminalsController).

Remaining (small): menu watermark (Free shows it, `watermark_removed` removes it —
needs the public-menu render), report-history (static ReportPlanGate) and AI-3D
routing — these already enforce the correct matrix values, just not yet editable.

86 tests pass; build clean.
2026-06-03 01:40:00 +03:30

147 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/settings")]
public class CafeSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IValidator<PatchCafeSettingsRequest> _validator;
private readonly IPlatformCatalogService _catalog;
public CafeSettingsController(
AppDbContext db,
IValidator<PatchCafeSettingsRequest> validator,
IPlatformCatalogService catalog)
{
_db = db;
_validator = validator;
_catalog = catalog;
}
[HttpGet]
public async Task<IActionResult> Get(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return NotFoundError();
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
[HttpPatch]
public async Task<IActionResult> Patch(
string cafeId,
[FromBody] PatchCafeSettingsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _validator.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)));
}
if (request.DefaultTaxRate is not null || request.AllowBranchTaxOverride is not null)
{
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
}
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return NotFoundError();
if (request.Name is not null) cafe.Name = request.Name.Trim();
if (request.Slug is not null)
{
var newSlug = request.Slug.Trim().ToLowerInvariant();
if (!SlugHelper.IsValidSlug(newSlug))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens.")));
var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct);
if (taken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another.")));
cafe.Slug = newSlug;
}
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
if (request.Address is not null) cafe.Address = request.Address.Trim();
if (request.City is not null) cafe.City = request.City.Trim();
if (request.Description is not null) cafe.Description = request.Description.Trim();
if (request.LogoUrl is not null) cafe.LogoUrl = request.LogoUrl.Trim();
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
if (request.Theme is not null)
{
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
// so a normal settings save that re-sends the current theme isn't rejected.
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
if (newThemeJson != cafe.ThemeJson)
{
var styleTier = tenant.PlanTier ?? PlanTier.Free;
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
cafe.ThemeJson = newThemeJson;
}
}
if (request.DefaultTaxRate is decimal taxRate)
cafe.DefaultTaxRate = taxRate;
if (request.AllowBranchTaxOverride is bool allowTax)
cafe.AllowBranchTaxOverride = allowTax;
// Location: explicit null-clear flag OR new values
if (request.ClearLocation)
{
cafe.Latitude = null;
cafe.Longitude = null;
}
else if (request.Latitude.HasValue && request.Longitude.HasValue)
{
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_LOCATION", "Latitude must be 90…90 and longitude 180…180.")));
cafe.Latitude = request.Latitude;
cafe.Longitude = request.Longitude;
}
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
private static CafeSettingsDto ToDto(Core.Entities.Cafe cafe) => new(
cafe.Id,
cafe.Name,
cafe.Slug,
cafe.Phone,
cafe.Address,
cafe.City,
cafe.Description,
cafe.LogoUrl,
cafe.CoverImageUrl,
cafe.SnappfoodVendorId,
cafe.PlanTier.ToString(),
cafe.PlanExpiresAt,
CafeThemeMapping.FromJson(cafe.ThemeJson),
cafe.DefaultTaxRate,
cafe.AllowBranchTaxOverride,
cafe.Latitude,
cafe.Longitude);
}