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,131 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ISmsMarketingService
|
||||
{
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SmsMarketingService : ISmsMarketingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<SmsMarketingService> _logger;
|
||||
|
||||
public SmsMarketingService(
|
||||
AppDbContext db,
|
||||
ISmsService smsService,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<SmsMarketingService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_smsService = smsService;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SmsUsageDto> GetUsageAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
var limit = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
if (maxSms == 0)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan.");
|
||||
|
||||
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
|
||||
if (phones.Count == 0)
|
||||
return (false, null, "NOT_FOUND", "No recipients found.");
|
||||
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||
|
||||
var sent = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var phone in phones)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(phone, request.Message, cancellationToken);
|
||||
sent++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send SMS to recipient");
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sent > 0)
|
||||
await IncrementUsageAsync(cafeId, month, sent);
|
||||
|
||||
return (true, new SmsCampaignResult(sent, failed), null, null);
|
||||
}
|
||||
|
||||
private async Task<List<string>> ResolvePhonesAsync(
|
||||
string cafeId,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Phones is { Count: > 0 })
|
||||
{
|
||||
return request.Phones
|
||||
.Select(PhoneNormalizer.Normalize)
|
||||
.Where(PhoneNormalizer.IsValidIranMobile)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var query = _db.Customers.Where(c => c.CafeId == cafeId);
|
||||
if (request.TargetGroup.HasValue)
|
||||
query = query.Where(c => c.Group == request.TargetGroup.Value);
|
||||
|
||||
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<int> GetUsedCountAsync(string cafeId, string month)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var value = await redis.StringGetAsync(UsageKey(cafeId, month));
|
||||
return value.HasValue ? (int)value : 0;
|
||||
}
|
||||
|
||||
private async Task IncrementUsageAsync(string cafeId, string month, int count)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var key = UsageKey(cafeId, month);
|
||||
await redis.StringIncrementAsync(key, count);
|
||||
await redis.KeyExpireAsync(key, TimeSpan.FromDays(40));
|
||||
}
|
||||
|
||||
private static string UsageKey(string cafeId, string month) => $"sms:usage:{cafeId}:{month}";
|
||||
}
|
||||
Reference in New Issue
Block a user