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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -0,0 +1,193 @@
using Meezi.API.Models.Auth;
using Meezi.API.Models.Consumer;
using Meezi.API.Security;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
namespace Meezi.API.Services;
public interface IConsumerAuthService
{
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
SendOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
}
public class ConsumerAuthService : IConsumerAuthService
{
private const int OtpTtlSeconds = 300;
private const int DefaultMaxOtpAttemptsPerHour = 5;
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
private readonly ISmsService _smsService;
private readonly IJwtTokenService _jwtTokenService;
private readonly IRefreshTokenStore _refreshTokenStore;
private readonly IConfiguration _configuration;
private readonly ILogger<ConsumerAuthService> _logger;
private readonly IAbuseProtectionService _abuse;
private readonly IHttpContextAccessor _http;
public ConsumerAuthService(
AppDbContext db,
IConnectionMultiplexer redis,
ISmsService smsService,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
IConfiguration configuration,
IAbuseProtectionService abuse,
IHttpContextAccessor http,
ILogger<ConsumerAuthService> logger)
{
_db = db;
_redis = redis;
_smsService = smsService;
_jwtTokenService = jwtTokenService;
_refreshTokenStore = refreshTokenStore;
_configuration = configuration;
_abuse = abuse;
_http = http;
_logger = logger;
}
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
SendOtpRequest request,
CancellationToken cancellationToken = default)
{
var phone = PhoneNormalizer.Normalize(request.Phone);
var redis = _redis.GetDatabase();
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
if (_http.HttpContext is not null)
{
var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
if (!ipCheck.Allowed)
return (false, null, ipCheck.ErrorCode, ipCheck.Message);
}
var attemptsKey = $"consumer-otp:attempts:{phone}";
if (maxAttempts > 0)
{
var attempts = await redis.StringGetAsync(attemptsKey);
if (attempts.HasValue && (int)attempts >= maxAttempts)
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
}
var otp = Random.Shared.Next(100000, 999999).ToString();
await redis.StringSetAsync($"consumer-otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
_logger.LogWarning("DEV consumer OTP for {Phone}: {Otp}", phone, otp);
try
{
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send consumer OTP SMS");
return (false, null, "SMS_FAILED", "Could not send verification code.");
}
if (maxAttempts > 0)
{
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
if (newAttempts == 1)
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
}
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
}
public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
VerifyOtpRequest request,
CancellationToken cancellationToken = default)
{
var phone = PhoneNormalizer.Normalize(request.Phone);
var code = OtpNormalizer.Normalize(request.Code);
if (!OtpNormalizer.IsValidSixDigitCode(code))
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var redis = _redis.GetDatabase();
var storedOtp = await redis.StringGetAsync($"consumer-otp:{phone}");
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var account = await _db.ConsumerAccounts
.FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken);
if (account is null)
{
account = new ConsumerAccount { Phone = phone };
_db.ConsumerAccounts.Add(account);
await _db.SaveChangesAsync(cancellationToken);
}
await redis.KeyDeleteAsync($"consumer-otp:{phone}");
var tokens = await IssueTokensAsync(account, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default)
{
var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
if (payload is null || payload.Actor != MeeziActorKinds.Consumer)
return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired.");
var account = await _db.ConsumerAccounts
.FirstOrDefaultAsync(a => a.Id == payload.UserId, cancellationToken);
if (account is null)
return (false, null, "NOT_FOUND", "Account no longer exists.");
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
var tokens = await IssueTokensAsync(account, cancellationToken);
return (true, tokens, null, null);
}
private async Task<ConsumerAuthTokenResponse> IssueTokensAsync(
ConsumerAccount account,
CancellationToken cancellationToken)
{
var lang = "fa";
var accessToken = _jwtTokenService.CreateConsumerAccessToken(account, lang);
var refreshToken = _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
await _refreshTokenStore.StoreAsync(
refreshToken,
new RefreshTokenPayload(
account.Id,
string.Empty,
MeeziRoles.Customer,
string.Empty,
lang,
MeeziActorKinds.Consumer),
TimeSpan.FromDays(refreshDays),
cancellationToken);
return new ConsumerAuthTokenResponse(
accessToken,
refreshToken,
_jwtTokenService.GetAccessTokenExpiry(),
account.Id,
account.Phone,
lang);
}
}