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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+3419
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>
|
||||
|
||||
Reference in New Issue
Block a user