Files
meezi/src/Meezi.API/Controllers/BranchesController.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

308 lines
12 KiB
C#

using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Branches;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches")]
public class BranchesController : CafeApiControllerBase
{
private const string PlanLimitMessage =
"Branch limit reached for your plan. Upgrade to Pro or Business to add more branches.";
private readonly AppDbContext _db;
private readonly IBranchLifecycleService _lifecycle;
private readonly IValidator<CreateBranchRequest> _createValidator;
private readonly IValidator<PatchBranchRequest> _patchValidator;
public BranchesController(
AppDbContext db,
IBranchLifecycleService lifecycle,
IValidator<CreateBranchRequest> createValidator,
IValidator<PatchBranchRequest> patchValidator)
{
_db = db;
_lifecycle = lifecycle;
_createValidator = createValidator;
_patchValidator = patchValidator;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
[FromQuery] bool activeOnly = false,
[FromQuery] bool includePendingDeletion = false,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var now = DateTime.UtcNow;
List<Branch> branches;
if (includePendingDeletion)
{
branches = await _db.Branches
.IgnoreQueryFilters()
.Where(b => b.CafeId == cafeId
&& (b.DeletedAt == null
|| (b.ScheduledPermanentDeleteAt != null && b.ScheduledPermanentDeleteAt > now)))
.OrderBy(b => b.DeletedAt == null ? 0 : 1)
.ThenBy(b => b.Name)
.ToListAsync(ct);
}
else
{
var query = _db.Branches.Where(b => b.CafeId == cafeId);
if (activeOnly)
query = query.Where(b => b.IsActive);
branches = await query.OrderBy(b => b.Name).ToListAsync(ct);
}
var branchIds = branches.Select(b => b.Id).ToList();
var managers = await _db.Employees
.AsNoTracking()
.IgnoreQueryFilters()
.Where(e => e.CafeId == cafeId && e.BranchId != null && branchIds.Contains(e.BranchId!)
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null)
.ToListAsync(ct);
var managerByBranch = managers
.GroupBy(e => e.BranchId!)
.ToDictionary(g => g.Key, g => g.First());
var data = branches.Select(b =>
{
managerByBranch.TryGetValue(b.Id, out var mgr);
return ToDto(b, mgr?.Phone, mgr?.Name, now);
}).ToList();
return Ok(new ApiResponse<IReadOnlyList<BranchDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateBranchRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.CreateBranch) is { } permDenied) return permDenied;
var validation = await _createValidator.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 loginPhone = PhoneNormalizer.Normalize(request.LoginPhone);
var phoneTaken = await _db.Employees.AnyAsync(
e => e.CafeId == cafeId && e.Phone == loginPhone && e.DeletedAt == null, ct);
if (phoneTaken)
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("PHONE_ALREADY_REGISTERED",
"This mobile number is already used for login at this cafe.")));
}
var tier = tenant.PlanTier ?? PlanTier.Free;
var count = await _db.Branches.CountAsync(b => b.CafeId == cafeId, ct);
var max = PlanLimits.MaxBranches(tier);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", PlanLimitMessage)));
var now = DateTime.UtcNow;
var branchId = $"branch_{Guid.NewGuid():N}"[..24];
var managerName = string.IsNullOrWhiteSpace(request.ManagerName)
? request.Name.Trim()
: request.ManagerName.Trim();
var branch = new Branch
{
Id = branchId,
CafeId = cafeId,
Name = request.Name.Trim(),
Address = request.Address?.Trim(),
City = request.City?.Trim(),
Phone = request.Phone?.Trim(),
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var employee = new Employee
{
Id = $"emp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Name = managerName,
Phone = loginPhone,
Role = EmployeeRole.Manager,
CreatedAt = now
};
_db.Branches.Add(branch);
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, loginPhone, managerName, now)));
}
[HttpPatch("{branchId}")]
public async Task<IActionResult> Patch(
string cafeId,
string branchId,
[FromBody] PatchBranchRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
var validation = await _patchValidator.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 branch = await _db.Branches.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (branch is null) return NotFoundError();
if (request.TaxRate.HasValue)
{
var cafe = await _db.Cafes.AsNoTracking().FirstAsync(c => c.Id == cafeId, ct);
if (!cafe.AllowBranchTaxOverride)
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("TAX_OVERRIDE_NOT_ALLOWED",
"تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است")));
}
}
if (request.Name is not null) branch.Name = request.Name.Trim();
if (request.Address is not null) branch.Address = request.Address.Trim();
if (request.City is not null) branch.City = request.City.Trim();
if (request.Phone is not null) branch.Phone = request.Phone.Trim();
if (request.IsActive.HasValue) branch.IsActive = request.IsActive.Value;
if (request.LogoUrl is not null) branch.LogoUrl = string.IsNullOrWhiteSpace(request.LogoUrl) ? null : request.LogoUrl.Trim();
if (request.WelcomeText is not null) branch.WelcomeText = string.IsNullOrWhiteSpace(request.WelcomeText) ? null : request.WelcomeText.Trim();
if (request.AccentColor is not null) branch.AccentColor = string.IsNullOrWhiteSpace(request.AccentColor) ? null : request.AccentColor.Trim();
if (request.WifiPassword is not null) branch.WifiPassword = string.IsNullOrWhiteSpace(request.WifiPassword) ? null : request.WifiPassword.Trim();
if (request.TaxRate.HasValue) branch.TaxRate = request.TaxRate.Value;
branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var mgr = await _db.Employees.AsNoTracking()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
[HttpDelete("{branchId}")]
public async Task<IActionResult> Delete(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.DeleteBranch) is { } permDenied) return permDenied;
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
if (!ok)
{
return code switch
{
"NOT_FOUND" => NotFoundError(),
"LAST_BRANCH" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code, message ?? "Cannot delete the last branch."))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code ?? "DELETE_FAILED", message ?? "Could not delete branch.")))
};
}
var branch = await _db.Branches
.IgnoreQueryFilters()
.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
var mgr = await _db.Employees.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == branch.DeletedAt, ct);
return Ok(new ApiResponse<BranchDto>(true,
ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
[HttpPost("{branchId}/restore")]
public async Task<IActionResult> Restore(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
if (!ok)
{
return code switch
{
"NOT_FOUND" => NotFoundError(),
"PURGE_EXPIRED" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code, message ?? "Recovery period has ended."))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code ?? "RESTORE_FAILED", message ?? "Could not restore branch.")))
};
}
var branch = await _db.Branches.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
var mgr = await _db.Employees.AsNoTracking()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
private static BranchDto ToDto(Branch b, string? loginPhone, string? managerName, DateTime utcNow)
{
var pending = b.DeletedAt is not null
&& b.ScheduledPermanentDeleteAt is not null
&& b.ScheduledPermanentDeleteAt > utcNow;
int? daysLeft = pending
? Math.Max(0, (int)Math.Ceiling((b.ScheduledPermanentDeleteAt!.Value - utcNow).TotalDays))
: null;
return new BranchDto(
b.Id,
b.Name,
b.Address,
b.City,
b.Phone,
b.IsActive && !pending,
loginPhone,
managerName,
pending,
b.DeletedAt,
b.ScheduledPermanentDeleteAt,
daysLeft);
}
}