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
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>
308 lines
12 KiB
C#
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);
|
|
}
|
|
}
|