Files
meezi/src/Meezi.API/Controllers/CafeSettingsController.cs
T
soroush.asadi 7a5ea75b50
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
feat(rbac): enforce permissions on every café write endpoint
Closes the gap where the custom-role matrix was defined but unenforced — most
write endpoints only checked café membership, so the API would accept writes a
role's UI hid. Adds EnsurePermission(...) to all mutating/sensitive endpoints
across 32 controllers, mapped to the granular catalog:

- menu/inventory/coupons/customers/expenses/reservations/taxes/branches → CRUD perms
- tables/queue/kitchen-stations/print-settings → manage perms
- orders → ProcessOrders / EditOrder / VoidOrder / UpdateOrderStatus / HandlePayments,
  payment corrections → ManageFinancials
- HR → CreateStaff / ManageSchedules / ReviewLeave / View+ManageSalaries /
  ManageStaffCredentials (self-service clock-in/leave preserved)
- reports → ViewReports, export → ExportReports, audit → ViewAuditLog
- billing → ManageBilling, sms → SendSms/ManageSmsSettings, reviews → ManageReviews,
  discover/public profile → ManageDiscoverProfile, café settings → ManageCafeSettings,
  custom roles → ManageRoles

Removes legacy [Authorize(Roles=...)] attributes that would have overridden the
permission model (orders, branch-menu, pos-device, print). Manual discount/comp
have no backend endpoint yet (discounts come from coupons) — gated on the POS UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:43:07 +03:30

149 lines
6.0 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.Authorization;
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;
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
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);
}