Fully implement Kavenegar SMS support

Core changes:
- ISmsService: add SendBulkAsync (batches of 200) + GetAccountInfoAsync
- KavenegarSmsService: POST requests, sender number config, bulk send
  via comma-separated receptors, account balance, full error code mapping
  (HTTP 400-432), enabled-flag check before any send
- SmsMarketingService: replaced per-recipient loop with SendBulkAsync
- SmsController: new GET /sms/balance endpoint returns Kavenegar credit
- SmsDtos: SmsBalanceDto
- IntegrationDtos + PlatformIntegrationService: SenderNumber field
- appsettings.json + docker-compose: Kavenegar__SenderNumber = 90005671

Dashboard:
- sms-screen: char counter, SMS parts indicator (Persian 70/67 chars,
  Latin 160/153), account balance card, sender line display, result banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 02:38:06 +03:30
parent b78f2affb6
commit 42d7667735
14 changed files with 446 additions and 110 deletions
@@ -11,16 +11,32 @@ namespace Meezi.API.Controllers;
public class SmsController : CafeApiControllerBase
{
private readonly ISmsMarketingService _smsMarketingService;
private readonly ISmsService _smsService;
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
public SmsController(
ISmsMarketingService smsMarketingService,
ISmsService smsService,
IValidator<SendSmsCampaignRequest> campaignValidator)
{
_smsMarketingService = smsMarketingService;
_smsService = smsService;
_campaignValidator = campaignValidator;
}
[HttpGet("balance")]
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
var dto = info is not null
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
: new SmsBalanceDto(0, "master", false);
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
}
[HttpGet("usage")]
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
+3
View File
@@ -10,3 +10,6 @@ public record SendSmsCampaignRequest(
public record SmsCampaignResult(int SentCount, int FailedCount);
public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
+5 -23
View File
@@ -24,18 +24,15 @@ 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)
IConnectionMultiplexer redis)
{
_db = db;
_smsService = smsService;
_redis = redis;
_logger = logger;
}
public async Task<SmsUsageDto> GetUsageAsync(
@@ -68,27 +65,12 @@ public class SmsMarketingService : ISmsMarketingService
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;
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
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 (result.SentCount > 0)
await IncrementUsageAsync(cafeId, month, result.SentCount);
if (sent > 0)
await IncrementUsageAsync(cafeId, month, sent);
return (true, new SmsCampaignResult(sent, failed), null, null);
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
}
private async Task<List<string>> ResolvePhonesAsync(
+1
View File
@@ -37,6 +37,7 @@
},
"Kavenegar": {
"ApiKey": "",
"SenderNumber": "90005671",
"OtpTemplate": "verify"
},
"ZarinPal": {