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 _createValidator; private readonly IValidator _patchValidator; public BranchesController( AppDbContext db, IBranchLifecycleService lifecycle, IValidator createValidator, IValidator patchValidator) { _db = db; _lifecycle = lifecycle; _createValidator = createValidator; _patchValidator = patchValidator; } [HttpGet] public async Task 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 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>(true, data)); } [HttpPost] public async Task 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(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(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(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(true, ToDto(branch, loginPhone, managerName, now))); } [HttpPatch("{branchId}")] public async Task 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(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(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(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow))); } [HttpDelete("{branchId}")] public async Task 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(false, null, new ApiError(code, message ?? "Cannot delete the last branch."))), _ => BadRequest(new ApiResponse(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(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow))); } [HttpPost("{branchId}/restore")] public async Task 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(false, null, new ApiError(code, message ?? "Recovery period has ended."))), _ => BadRequest(new ApiResponse(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(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); } }