feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Branches;
|
||||
using Meezi.API.Services;
|
||||
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 (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
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 (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
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 (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
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 (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user