feat(sms): bring-your-own-provider — cafés use their own SMS account
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s

The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.

Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
  against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
  error when missing; plan-tier SMS gating removed everywhere
  (PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
  without booting the host

Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
  form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits

Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
  optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"

fa/en/ar translations. 86 tests pass; all tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 09:23:50 +03:30
parent 615d5348de
commit 00649d0248
24 changed files with 3953 additions and 188 deletions
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Meezi.Infrastructure.Data;
/// <summary>
/// Design-time factory so `dotnet ef migrations add` works without booting the
/// full API host (which needs Redis etc.). Generating a migration never connects
/// to the database, so a placeholder connection string is fine; for commands that
/// DO connect (database update), set MEEZI_DESIGNTIME_DB to a real string.
/// </summary>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var conn = Environment.GetEnvironmentVariable("MEEZI_DESIGNTIME_DB")
?? "Host=localhost;Database=meezi;Username=meezi;Password=design-time-only";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(conn)
.Options;
return new AppDbContext(options);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCafeSmsCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SmsApiKey",
table: "Cafes",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SmsSenderNumber",
table: "Cafes",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SmsApiKey",
table: "Cafes");
migrationBuilder.DropColumn(
name: "SmsSenderNumber",
table: "Cafes");
}
}
}
@@ -370,6 +370,12 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SmsApiKey")
.HasColumnType("text");
b.Property<string>("SmsSenderNumber")
.HasColumnType("text");
b.Property<string>("SnappfoodVendorId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
@@ -123,6 +123,12 @@ public class KavenegarSmsService : ISmsService
{
var (apiKey, _, _) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey)) return null;
return await GetAccountInfoAsync(apiKey, cancellationToken);
}
public async Task<KavenegarAccountInfo?> GetAccountInfoAsync(string apiKey, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(apiKey)) return null;
try
{
@@ -140,6 +146,46 @@ public class KavenegarSmsService : ISmsService
}
}
/// <summary>
/// Bulk send with a café's OWN credentials — marketing SMS is bring-your-own-provider,
/// the platform account is only used for OTP and system messages.
/// </summary>
public async Task<BulkSendResult> SendBulkWithCredentialsAsync(
string apiKey,
string senderNumber,
IReadOnlyList<string> phones,
string message,
CancellationToken cancellationToken = default)
{
if (phones.Count == 0) return new BulkSendResult(0, 0);
if (string.IsNullOrWhiteSpace(apiKey))
return new BulkSendResult(0, phones.Count);
int sent = 0, failed = 0;
foreach (var batch in phones.Chunk(MaxBatchSize))
{
try
{
var receptors = batch.Select(NormalizePhone).ToList();
await RunSdkAsync(apiKey, api =>
{
api.Send(senderNumber, receptors, message);
}, "BulkSendOwn");
sent += batch.Length;
_logger.LogInformation("Kavenegar own-credentials bulk batch: {Count} sent", batch.Length);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Kavenegar own-credentials bulk batch failed ({Count} recipients)", batch.Length);
failed += batch.Length;
}
}
return new BulkSendResult(sent, failed);
}
// ── SDK runner ────────────────────────────────────────────────────────────
/// <summary>