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
+42 -15
View File
@@ -7,33 +7,64 @@ using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
/// <summary>
/// Marketing SMS — bring-your-own-provider. Each café configures its OWN
/// Kavenegar API key + sender line; the platform does not sell SMS.
/// </summary>
[Route("api/cafes/{cafeId}/sms")] [Route("api/cafes/{cafeId}/sms")]
public class SmsController : CafeApiControllerBase public class SmsController : CafeApiControllerBase
{ {
private readonly ISmsMarketingService _smsMarketingService; private readonly ISmsMarketingService _smsMarketingService;
private readonly ISmsService _smsService;
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator; private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
public SmsController( public SmsController(
ISmsMarketingService smsMarketingService, ISmsMarketingService smsMarketingService,
ISmsService smsService,
IValidator<SendSmsCampaignRequest> campaignValidator) IValidator<SendSmsCampaignRequest> campaignValidator)
{ {
_smsMarketingService = smsMarketingService; _smsMarketingService = smsMarketingService;
_smsService = smsService;
_campaignValidator = campaignValidator; _campaignValidator = campaignValidator;
} }
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var data = await _smsMarketingService.GetSettingsAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings(
string cafeId,
[FromBody] UpdateSmsSettingsRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
cafeId, request, cancellationToken);
if (!success)
{
return code switch
{
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
};
}
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
}
[HttpGet("balance")] [HttpGet("balance")]
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var info = await _smsService.GetAccountInfoAsync(cancellationToken); var dto = await _smsMarketingService.GetBalanceAsync(cafeId, 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)); return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
} }
@@ -41,10 +72,8 @@ public class SmsController : CafeApiControllerBase
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken); var data = await _smsMarketingService.GetUsageAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<SmsUsageDto>(true, data)); return Ok(new ApiResponse<SmsUsageDto>(true, data));
} }
@@ -56,20 +85,18 @@ public class SmsController : CafeApiControllerBase
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken); var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation)); if (!validation.IsValid) return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync( var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
cafeId, tenant.PlanTier.Value, request, cancellationToken); cafeId, request, cancellationToken);
if (!success) if (!success)
{ {
return code switch return code switch
{ {
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden, "SMS_NOT_CONFIGURED" => BadRequest(
new ApiResponse<object>(false, null, new ApiError(code, message!))), new ApiResponse<object>(false, null, new ApiError(code, message!))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))), "NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!))) _ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
@@ -15,8 +15,6 @@ public record BillingStatusDto(
int? OrdersDailyLimit, int? OrdersDailyLimit,
int CustomersCount, int CustomersCount,
int? CustomersLimit, int? CustomersLimit,
int SmsUsedThisMonth,
int SmsMonthlyLimit,
bool Menu3dEnabled, bool Menu3dEnabled,
bool MenuAi3dEnabled, bool MenuAi3dEnabled,
int MenuAi3dUsedThisMonth, int MenuAi3dUsedThisMonth,
+8
View File
@@ -13,3 +13,11 @@ public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary> /// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured); public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
/// <summary>
/// Café's own SMS provider settings (bring-your-own-provider). The API key is
/// returned masked — only the last 4 characters are ever echoed back.
/// </summary>
public record SmsSettingsDto(bool IsConfigured, string? ApiKeyMasked, string? SenderNumber);
public record UpdateSmsSettingsRequest(string? ApiKey, string? SenderNumber);
-7
View File
@@ -379,12 +379,7 @@ public class BillingService : IBillingService
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier); var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier); var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
var redis = _redis.GetDatabase(); var redis = _redis.GetDatabase();
var smsUsed = await redis.StringGetAsync(monthKey);
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync( var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken); cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
@@ -406,8 +401,6 @@ public class BillingService : IBillingService
maxOrders == int.MaxValue ? null : maxOrders, maxOrders == int.MaxValue ? null : maxOrders,
customersCount, customersCount,
maxCustomers == int.MaxValue ? null : maxCustomers, maxCustomers == int.MaxValue ? null : maxCustomers,
smsUsedCount,
maxSms == int.MaxValue ? -1 : maxSms,
menu3d, menu3d,
menuAi3d, menuAi3d,
ai3dUsedCount, ai3dUsedCount,
+3 -20
View File
@@ -114,26 +114,9 @@ public class PlanLimitChecker : IPlanLimitChecker
} }
} }
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign"; // NOTE: SMS is deliberately NOT plan-gated — marketing SMS is
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) || // bring-your-own-provider (the café's own API key + sender line), so the
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase)) // café's provider account is the only limit.
{
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
var maxSms = limitsSms.MaxSmsPerMonth;
if (maxSms == 0)
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
if (maxSms == int.MaxValue)
return (true, null, null);
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
var redis = _redis.GetDatabase();
var used = await redis.StringGetAsync(monthKey);
var usedCount = used.HasValue ? (int)used : 0;
if (usedCount >= maxSms)
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
}
return (true, null, null); return (true, null, null);
} }
+103 -20
View File
@@ -1,6 +1,4 @@
using Meezi.API.Models.Crm; using Meezi.API.Models.Crm;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Core.Utilities; using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data; using Meezi.Infrastructure.Data;
@@ -11,14 +9,25 @@ namespace Meezi.API.Services;
public interface ISmsMarketingService public interface ISmsMarketingService
{ {
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default); Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default);
Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default);
Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
string cafeId,
UpdateSmsSettingsRequest request,
CancellationToken cancellationToken = default);
Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default);
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync( Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
string cafeId, string cafeId,
PlanTier planTier,
SendSmsCampaignRequest request, SendSmsCampaignRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
/// <summary>
/// Marketing SMS is bring-your-own-provider: each café configures its OWN
/// Kavenegar API key + sender line and pays its provider directly. The platform
/// neither sells SMS nor meters it against plan limits; the monthly counter is
/// informational only. (Login OTPs still go through the platform account.)
/// </summary>
public class SmsMarketingService : ISmsMarketingService public class SmsMarketingService : ISmsMarketingService
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@@ -35,40 +44,111 @@ public class SmsMarketingService : ISmsMarketingService
_redis = redis; _redis = redis;
} }
public async Task<SmsUsageDto> GetUsageAsync( public async Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default)
string cafeId,
PlanTier planTier,
CancellationToken cancellationToken = default)
{ {
var month = DateTime.UtcNow.ToString("yyyy-MM"); var month = DateTime.UtcNow.ToString("yyyy-MM");
var used = await GetUsedCountAsync(cafeId, month); var used = await GetUsedCountAsync(cafeId, month);
var limit = PlanLimits.MaxSmsPerMonth(planTier); // -1 = no platform limit; the café's own provider account is the only cap.
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month); return new SmsUsageDto(used, -1, month);
}
public async Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == cafeId)
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
.FirstOrDefaultAsync(cancellationToken);
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey))
return new SmsSettingsDto(false, null, cafe?.SmsSenderNumber);
return new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey), cafe.SmsSenderNumber);
}
public async Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
string cafeId,
UpdateSmsSettingsRequest request,
CancellationToken cancellationToken = default)
{
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null)
return (false, null, "NOT_FOUND", "Cafe not found.");
var apiKey = request.ApiKey?.Trim();
var sender = request.SenderNumber?.Trim();
// Empty strings clear the configuration (turn SMS off for this café).
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(sender))
{
cafe.SmsApiKey = null;
cafe.SmsSenderNumber = null;
await _db.SaveChangesAsync(cancellationToken);
return (true, new SmsSettingsDto(false, null, null), null, null);
}
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(cafe.SmsApiKey))
return (false, null, "VALIDATION_ERROR", "API key is required.");
if (string.IsNullOrEmpty(sender))
return (false, null, "VALIDATION_ERROR", "Sender number is required.");
// A new key was provided — verify it against the provider before saving so
// the owner gets immediate feedback on a typo'd key.
if (!string.IsNullOrEmpty(apiKey))
{
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
if (info is null)
return (false, null, "SMS_KEY_INVALID", "The API key was rejected by the SMS provider.");
cafe.SmsApiKey = apiKey;
}
cafe.SmsSenderNumber = sender;
await _db.SaveChangesAsync(cancellationToken);
return (true, new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey!), cafe.SmsSenderNumber), null, null);
}
public async Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default)
{
var apiKey = await _db.Cafes.AsNoTracking()
.Where(c => c.Id == cafeId)
.Select(c => c.SmsApiKey)
.FirstOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
return new SmsBalanceDto(0, "master", false);
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
return info is not null
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
: new SmsBalanceDto(0, "master", false);
} }
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync( public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
string cafeId, string cafeId,
PlanTier planTier,
SendSmsCampaignRequest request, SendSmsCampaignRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var maxSms = PlanLimits.MaxSmsPerMonth(planTier); var cafe = await _db.Cafes.AsNoTracking()
if (maxSms == 0) .Where(c => c.Id == cafeId)
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan."); .Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
.FirstOrDefaultAsync(cancellationToken);
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey) || string.IsNullOrWhiteSpace(cafe.SmsSenderNumber))
return (false, null, "SMS_NOT_CONFIGURED",
"Configure your own SMS provider (API key + sender line) in the SMS settings first.");
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken); var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
if (phones.Count == 0) if (phones.Count == 0)
return (false, null, "NOT_FOUND", "No recipients found."); return (false, null, "NOT_FOUND", "No recipients found.");
var month = DateTime.UtcNow.ToString("yyyy-MM"); var result = await _smsService.SendBulkWithCredentialsAsync(
var used = await GetUsedCountAsync(cafeId, month); cafe.SmsApiKey, cafe.SmsSenderNumber, phones, request.Message, cancellationToken);
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
if (result.SentCount > 0) if (result.SentCount > 0)
{
var month = DateTime.UtcNow.ToString("yyyy-MM");
await IncrementUsageAsync(cafeId, month, result.SentCount); await IncrementUsageAsync(cafeId, month, result.SentCount);
}
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null); return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
} }
@@ -94,6 +174,9 @@ public class SmsMarketingService : ISmsMarketingService
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken); return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
} }
private static string MaskApiKey(string apiKey) =>
apiKey.Length <= 4 ? "****" : $"****{apiKey[^4..]}";
private async Task<int> GetUsedCountAsync(string cafeId, string month) private async Task<int> GetUsedCountAsync(string cafeId, string month)
{ {
var redis = _redis.GetDatabase(); var redis = _redis.GetDatabase();
@@ -55,8 +55,11 @@ public record PlatformIntegrationsDto(
public record UpdatePlatformIntegrationsRequest( public record UpdatePlatformIntegrationsRequest(
string ActivePaymentGateway, string ActivePaymentGateway,
IReadOnlyList<UpdatePaymentGatewayRequest> PaymentGateways, IReadOnlyList<UpdatePaymentGatewayRequest> PaymentGateways,
UpdateKavenegarRequest Kavenegar, // Optional: the admin UI no longer manages SMS (marketing SMS is
UpdateAiIntegrationsRequest Ai); // bring-your-own-provider per café). When null, the stored platform
// Kavenegar config — still used for login OTPs — is left untouched.
UpdateKavenegarRequest? Kavenegar = null,
UpdateAiIntegrationsRequest? Ai = null);
public record UpdateOpenAiIntegrationRequest( public record UpdateOpenAiIntegrationRequest(
bool IsEnabled, bool IsEnabled,
@@ -107,23 +107,32 @@ public class PlatformIntegrationService : IPlatformIntegrationService
await SaveCredentialsAsync(meta.Prefix, gw.Id, gw.Credentials, ct); await SaveCredentialsAsync(meta.Prefix, gw.Id, gw.Credentials, ct);
} }
await UpsertAsync(KeyKavenegarEnabled, request.Kavenegar.IsEnabled ? "true" : "false", "integrations", "فعال کاوه‌نگار", ct); // SMS (Kavenegar) is no longer managed from the admin UI — marketing SMS is
await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct); // bring-your-own-provider per café, and the platform OTP credentials live in
if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey)) // env/appsettings (or the previously-stored DB values, left untouched here).
await UpsertAsync(KeyKavenegarApi, request.Kavenegar.ApiKey.Trim(), "integrations", "API Key کاوه‌نگار", ct); if (request.Kavenegar is { } kavenegar)
if (!string.IsNullOrWhiteSpace(request.Kavenegar.SenderNumber)) {
await UpsertAsync(KeyKavenegarSender, request.Kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوه‌نگار", ct); await UpsertAsync(KeyKavenegarEnabled, kavenegar.IsEnabled ? "true" : "false", "integrations", "فعال کاوه‌نگار", ct);
await UpsertAsync(KeyKavenegarOtpTemplate, kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct);
if (!string.IsNullOrWhiteSpace(kavenegar.ApiKey) && !IsMaskedPlaceholder(kavenegar.ApiKey))
await UpsertAsync(KeyKavenegarApi, kavenegar.ApiKey.Trim(), "integrations", "API Key کاوه‌نگار", ct);
if (!string.IsNullOrWhiteSpace(kavenegar.SenderNumber))
await UpsertAsync(KeyKavenegarSender, kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوه‌نگار", ct);
}
await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, request.Ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct); if (request.Ai is { } ai)
await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(request.Ai.OpenAi.Model) ? "gpt-4o-mini" : request.Ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct); {
await UpsertAsync(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled, request.Ai.OpenAi.CoffeeAdvisorEnabled ? "true" : "false", "integrations", "مشاور قهوه OpenAI", ct); await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct);
if (!string.IsNullOrWhiteSpace(request.Ai.OpenAi.ApiKey) && !IsMaskedPlaceholder(request.Ai.OpenAi.ApiKey)) await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(ai.OpenAi.Model) ? "gpt-4o-mini" : ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct);
await UpsertAsync(PlatformIntegrationKeys.OpenAiApiKey, request.Ai.OpenAi.ApiKey.Trim(), "integrations", "API Key OpenAI", ct); await UpsertAsync(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled, ai.OpenAi.CoffeeAdvisorEnabled ? "true" : "false", "integrations", "مشاور قهوه OpenAI", ct);
if (!string.IsNullOrWhiteSpace(ai.OpenAi.ApiKey) && !IsMaskedPlaceholder(ai.OpenAi.ApiKey))
await UpsertAsync(PlatformIntegrationKeys.OpenAiApiKey, ai.OpenAi.ApiKey.Trim(), "integrations", "API Key OpenAI", ct);
await UpsertAsync(PlatformIntegrationKeys.MeshyEnabled, request.Ai.Meshy.IsEnabled ? "true" : "false", "integrations", "فعال Meshy", ct); await UpsertAsync(PlatformIntegrationKeys.MeshyEnabled, ai.Meshy.IsEnabled ? "true" : "false", "integrations", "فعال Meshy", ct);
await UpsertAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, request.Ai.Meshy.Menu3dEnabled ? "true" : "false", "integrations", "ساخت ۳D منو با Meshy", ct); await UpsertAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, ai.Meshy.Menu3dEnabled ? "true" : "false", "integrations", "ساخت ۳D منو با Meshy", ct);
if (!string.IsNullOrWhiteSpace(request.Ai.Meshy.ApiKey) && !IsMaskedPlaceholder(request.Ai.Meshy.ApiKey)) if (!string.IsNullOrWhiteSpace(ai.Meshy.ApiKey) && !IsMaskedPlaceholder(ai.Meshy.ApiKey))
await UpsertAsync(PlatformIntegrationKeys.MeshyApiKey, request.Ai.Meshy.ApiKey.Trim(), "integrations", "API Key Meshy", ct); await UpsertAsync(PlatformIntegrationKeys.MeshyApiKey, ai.Meshy.ApiKey.Trim(), "integrations", "API Key Meshy", ct);
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_catalog.InvalidateCache(); _catalog.InvalidateCache();
+6
View File
@@ -48,6 +48,12 @@ public class Cafe : BaseEntity
public decimal DefaultTaxRate { get; set; } = 9m; public decimal DefaultTaxRate { get; set; } = 9m;
public bool AllowBranchTaxOverride { get; set; } public bool AllowBranchTaxOverride { get; set; }
/// <summary>Café's own Kavenegar API key — marketing SMS is bring-your-own-provider;
/// the platform does not sell SMS. Null = SMS not configured for this café.</summary>
public string? SmsApiKey { get; set; }
/// <summary>Café's own SMS sender line number (e.g. 10004346).</summary>
public string? SmsSenderNumber { get; set; }
public ICollection<Branch> Branches { get; set; } = []; public ICollection<Branch> Branches { get; set; } = [];
public ICollection<Table> Tables { get; set; } = []; public ICollection<Table> Tables { get; set; } = [];
public ICollection<Employee> Employees { get; set; } = []; public ICollection<Employee> Employees { get; set; } = [];
+15
View File
@@ -20,4 +20,19 @@ public interface ISmsService
/// <summary>Returns credit balance from the Kavenegar account, or null if not configured.</summary> /// <summary>Returns credit balance from the Kavenegar account, or null if not configured.</summary>
Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default); Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Bulk send using the CALLER's own provider credentials — marketing SMS is
/// bring-your-own-provider (each café configures its own API key + sender line).
/// Never throws — failures per batch are counted and returned.
/// </summary>
Task<BulkSendResult> SendBulkWithCredentialsAsync(
string apiKey,
string senderNumber,
IReadOnlyList<string> phones,
string message,
CancellationToken cancellationToken = default);
/// <summary>Credit balance for an EXPLICIT API key (a café's own account), or null on failure.</summary>
Task<KavenegarAccountInfo?> GetAccountInfoAsync(string apiKey, CancellationToken cancellationToken = default);
} }
@@ -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) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("SmsApiKey")
.HasColumnType("text");
b.Property<string>("SmsSenderNumber")
.HasColumnType("text");
b.Property<string>("SnappfoodVendorId") b.Property<string>("SnappfoodVendorId")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
@@ -123,6 +123,12 @@ public class KavenegarSmsService : ISmsService
{ {
var (apiKey, _, _) = await GetConfigAsync(cancellationToken); var (apiKey, _, _) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey)) return null; 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 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 ──────────────────────────────────────────────────────────── // ── SDK runner ────────────────────────────────────────────────────────────
/// <summary> /// <summary>
+1 -1
View File
@@ -1020,7 +1020,7 @@
"title": "مدیریت سامانه", "title": "مدیریت سامانه",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"plans": "اشتراک و قیمت", "plans": "اشتراک و قیمت",
"integrations": "درگاه و پیامک", "integrations": "درگاه پرداخت و AI",
"notifications": "اعلان‌ها", "notifications": "اعلان‌ها",
"settings": "تنظیمات اپ", "settings": "تنظیمات اپ",
"features": "قابلیت‌ها", "features": "قابلیت‌ها",
@@ -143,7 +143,7 @@ const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
{ key: "maxMenuItems", label: "maxItems" }, { key: "maxMenuItems", label: "maxItems" },
{ key: "maxCustomers", label: "maxCustomers" }, { key: "maxCustomers", label: "maxCustomers" },
{ key: "maxReportHistoryDays", label: "maxReportDays" }, { key: "maxReportHistoryDays", label: "maxReportDays" },
{ key: "maxSmsPerMonth", label: "maxSms" }, // No SMS limit — marketing SMS is bring-your-own-provider per café.
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" }, { key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
]; ];
@@ -701,11 +701,6 @@ export function AdminIntegrationsScreen() {
hasStoredClientSecret: prev?.hasStoredClientSecret ?? false, hasStoredClientSecret: prev?.hasStoredClientSecret ?? false,
...patch, ...patch,
}); });
const [kavenegar, setKavenegar] = useState({
isEnabled: true,
apiKey: "",
otpTemplate: "verify",
});
const [openAi, setOpenAi] = useState({ const [openAi, setOpenAi] = useState({
isEnabled: false, isEnabled: false,
apiKey: "", apiKey: "",
@@ -722,11 +717,6 @@ export function AdminIntegrationsScreen() {
if (!data) return; if (!data) return;
setActiveGateway(data.activePaymentGateway); setActiveGateway(data.activePaymentGateway);
setGateways(data.paymentGateways.map((g) => ({ ...g }))); setGateways(data.paymentGateways.map((g) => ({ ...g })));
setKavenegar({
isEnabled: data.kavenegar.isEnabled,
apiKey: data.kavenegar.apiKey ?? "",
otpTemplate: data.kavenegar.otpTemplate,
});
setOpenAi({ setOpenAi({
isEnabled: data.ai.openAi.isEnabled, isEnabled: data.ai.openAi.isEnabled,
apiKey: data.ai.openAi.apiKey ?? "", apiKey: data.ai.openAi.apiKey ?? "",
@@ -770,7 +760,7 @@ export function AdminIntegrationsScreen() {
} }
: undefined, : undefined,
})), })),
kavenegar, // SMS is bring-your-own-provider per café — no platform SMS config here.
ai: { openAi, meshy }, ai: { openAi, meshy },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -998,39 +988,6 @@ export function AdminIntegrationsScreen() {
))} ))}
</section> </section>
<section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("kavenegarTitle")}
</p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("apiKey")}
<Input
className="mt-1"
type="password"
placeholder={data?.kavenegar.hasStoredApiKey ? "••••••••" : ""}
value={kavenegar.apiKey}
onChange={(e) => setKavenegar((k) => ({ ...k, apiKey: e.target.value }))}
/>
</label>
<label className="block text-sm">
{t("otpTemplate")}
<Input
className="mt-1"
value={kavenegar.otpTemplate}
onChange={(e) => setKavenegar((k) => ({ ...k, otpTemplate: e.target.value }))}
/>
</label>
</Card>
</section>
<section className="space-y-3"> <section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground"> <p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("aiTitle")} {t("aiTitle")}
+28 -2
View File
@@ -481,10 +481,36 @@
"targetGroup": "المجموعة المستهدفة", "targetGroup": "المجموعة المستهدفة",
"allCustomers": "كل العملاء", "allCustomers": "كل العملاء",
"send": "إرسال", "send": "إرسال",
"usage": "الاستخدام هذا الشهر", "usage": "المُرسَل هذا الشهر",
"unlimited": "غير محدود", "unlimited": "غير محدود",
"sent": "تم الإرسال", "sent": "تم الإرسال",
"failed": "فشل" "failed": "فشل",
"charCount": "{count} حرفاً",
"smsPartsHint": "{parts} رسالة",
"balance": "رصيد حسابك",
"balanceAmount": "{amount} ريال",
"balanceNotConfigured": "خدمة SMS غير مفعّلة",
"sender": "خط الإرسال",
"recipientsCount": "{count} مستلماً",
"sendConfirm": "إرسال إلى {count} شخصاً؟",
"sending": "جارٍ الإرسال...",
"byoHint": "تُرسل الرسائل عبر حسابك وخطك الخاص — تُحتسب تكلفة الإرسال مباشرة لدى مزوّد SMS الخاص بك.",
"notConfiguredOwner": "لإرسال الرسائل، احفظ أولاً مفتاح API ورقم خط كاوه‌نگار في الإعدادات أعلاه.",
"notConfiguredStaff": "لم يقم مدير المقهى بإعداد خدمة SMS بعد.",
"settings": {
"title": "إعدادات مزوّد SMS",
"hint": "أنشئ مفتاح API من لوحة كاوه‌نگار (kavenegar.com) وأدخله مع رقم خط الإرسال.",
"apiKey": "مفتاح API",
"apiKeyPlaceholder": "API Key",
"senderNumber": "رقم خط الإرسال",
"senderPlaceholder": "10004346...",
"configured": "خدمة SMS مفعّلة.",
"notConfigured": "لم يتم الإعداد بعد.",
"save": "حفظ",
"saving": "جارٍ التحقق…",
"saved": "تم حفظ إعدادات SMS.",
"saveFailed": "مفتاح API غير صالح أو فشل الحفظ."
}
}, },
"reports": { "reports": {
"title": "التقارير والتحليلات", "title": "التقارير والتحليلات",
+21 -4
View File
@@ -500,19 +500,36 @@
"targetGroup": "Target group", "targetGroup": "Target group",
"allCustomers": "All customers", "allCustomers": "All customers",
"send": "Send", "send": "Send",
"usage": "Usage this month", "usage": "Sent this month",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"sent": "Sent", "sent": "Sent",
"failed": "Failed", "failed": "Failed",
"charCount": "{count} chars", "charCount": "{count} chars",
"smsPartsHint": "{parts} SMS", "smsPartsHint": "{parts} SMS",
"balance": "Account credit", "balance": "Your account credit",
"balanceAmount": "{amount} Rials", "balanceAmount": "{amount} Rials",
"balanceNotConfigured": "Kavenegar not configured", "balanceNotConfigured": "SMS service not set up",
"sender": "Sender line", "sender": "Sender line",
"recipientsCount": "{count} recipients", "recipientsCount": "{count} recipients",
"sendConfirm": "Send to {count} people?", "sendConfirm": "Send to {count} people?",
"sending": "Sending..." "sending": "Sending...",
"byoHint": "SMS is sent through your OWN provider account and line — sending costs are billed directly by your SMS provider.",
"notConfiguredOwner": "To send SMS, first save your Kavenegar API key and sender line in the settings above.",
"notConfiguredStaff": "The SMS service has not been set up by the café manager yet.",
"settings": {
"title": "SMS provider settings",
"hint": "Create an API key in your Kavenegar panel (kavenegar.com) and enter it with your sender line number.",
"apiKey": "Kavenegar API key",
"apiKeyPlaceholder": "API Key",
"senderNumber": "Sender line number",
"senderPlaceholder": "10004346...",
"configured": "SMS service is active.",
"notConfigured": "Not set up yet.",
"save": "Save",
"saving": "Verifying…",
"saved": "SMS settings saved.",
"saveFailed": "The API key is invalid or saving failed."
}
}, },
"reports": { "reports": {
"title": "Reports & analytics", "title": "Reports & analytics",
+22 -5
View File
@@ -500,19 +500,36 @@
"targetGroup": "گروه هدف", "targetGroup": "گروه هدف",
"allCustomers": "همه مشتریان", "allCustomers": "همه مشتریان",
"send": "ارسال", "send": "ارسال",
"usage": "مصرف این ماه", "usage": "ارسال‌شده این ماه",
"unlimited": "نامحدود", "unlimited": "نامحدود",
"sent": "ارسال شد", "sent": "ارسال شد",
"failed": "ناموفق", "failed": "ناموفق",
"charCount": "{count} حرف", "charCount": "{count} حرف",
"smsPartsHint": "{parts} پیامک", "smsPartsHint": "{parts} پیامک",
"balance": "اعتبار حساب", "balance": "اعتبار حساب شما",
"balanceAmount": "{amount} ریال", "balanceAmount": "{amount} ریال",
"balanceNotConfigured": "Kavenegar پیکربندی نشده", "balanceNotConfigured": "سرویس پیامک راه‌اندازی نشده",
"sender": "خط فرستنده", "sender": "خط فرستنده",
"recipientsCount": "{count} مخاطب", "recipientsCount": "{count} مخاطب",
"sendConfirm": "ارسال به {count} نفر؟", "sendConfirm": "ارسال به {count} نفر؟",
"sending": "در حال ارسال..." "sending": "در حال ارسال...",
"byoHint": "پیامک با حساب و خط اختصاصی خود شما ارسال می‌شود — هزینه ارسال مستقیماً با اپراتور پیامک شماست.",
"notConfiguredOwner": "برای ارسال پیامک ابتدا کلید API و شماره خط کاوه‌نگار خود را در تنظیمات بالا ثبت کنید.",
"notConfiguredStaff": "سرویس پیامک هنوز توسط مدیر کافه راه‌اندازی نشده است.",
"settings": {
"title": "تنظیمات سرویس پیامک",
"hint": "از پنل کاوه‌نگار (kavenegar.com) کلید API بسازید و همراه شماره خط خود وارد کنید.",
"apiKey": "کلید API کاوه‌نگار",
"apiKeyPlaceholder": "API Key",
"senderNumber": "شماره خط ارسال",
"senderPlaceholder": "10004346...",
"configured": "سرویس پیامک فعال است.",
"notConfigured": "هنوز راه‌اندازی نشده.",
"save": "ذخیره",
"saving": "در حال بررسی…",
"saved": "تنظیمات پیامک ذخیره شد.",
"saveFailed": "کلید API نامعتبر است یا ذخیره ناموفق بود."
}
}, },
"reports": { "reports": {
"title": "گزارش‌ها و تحلیل", "title": "گزارش‌ها و تحلیل",
@@ -1481,7 +1498,7 @@
"title": "مدیریت سامانه", "title": "مدیریت سامانه",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"plans": "اشتراک و قیمت", "plans": "اشتراک و قیمت",
"integrations": "درگاه و پیامک", "integrations": "درگاه پرداخت و AI",
"notifications": "اعلان‌ها", "notifications": "اعلان‌ها",
"settings": "تنظیمات اپ", "settings": "تنظیمات اپ",
"features": "قابلیت‌ها", "features": "قابلیت‌ها",
@@ -19,13 +19,13 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
/** Limit rows shown at the top of the comparison, in display order. */ /** Limit rows shown at the top of the comparison, in display order. */
// NOTE: no SMS row — marketing SMS is bring-your-own-provider, not a plan limit.
const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [ const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [
{ key: "maxOrdersPerDay" }, { key: "maxOrdersPerDay" },
{ key: "maxBranches" }, { key: "maxBranches" },
{ key: "maxTerminals" }, { key: "maxTerminals" },
{ key: "maxTables" }, { key: "maxTables" },
{ key: "maxCustomers" }, { key: "maxCustomers" },
{ key: "maxSmsPerMonth", zeroAsDash: true },
{ key: "maxMenuItems" }, { key: "maxMenuItems" },
{ key: "maxReportHistoryDays" }, { key: "maxReportHistoryDays" },
{ key: "maxMenuAi3dPerMonth", zeroAsDash: true }, { key: "maxMenuAi3dPerMonth", zeroAsDash: true },
+131 -42
View File
@@ -3,17 +3,20 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { MessageSquare, Zap, Users } from "lucide-react"; import { KeyRound, MessageSquare, Settings2, Zap } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost, apiPut } from "@/lib/api/client";
import type { CustomerGroup, SmsCampaignResult, SmsUsage, SmsBalance } from "@/lib/api/types"; import type { CustomerGroup, SmsCampaignResult, SmsSettings, SmsUsage, SmsBalance } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify, notifyError } from "@/lib/notify";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"]; const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
/** Kavenegar SMS character limits. */ /** Kavenegar SMS character limits. */
function calcSmsParts(text: string): { chars: number; parts: number } { function calcSmsParts(text: string): { chars: number; parts: number } {
@@ -32,13 +35,21 @@ export function SmsScreen() {
const tCrm = useTranslations("crm"); const tCrm = useTranslations("crm");
const locale = useLocale(); const locale = useLocale();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canManage = MANAGER_ROLES.has(role ?? "");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [target, setTarget] = useState<CustomerGroup | "all">("all"); const [target, setTarget] = useState<CustomerGroup | "all">("all");
const [result, setResult] = useState<SmsCampaignResult | null>(null); const [result, setResult] = useState<SmsCampaignResult | null>(null);
// ── API queries ───────────────────────────────────────────────────────────── // ── API queries ─────────────────────────────────────────────────────────────
const { data: settings } = useQuery({
queryKey: ["sms-settings", cafeId],
queryFn: () => apiGet<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`),
enabled: !!cafeId && canManage,
});
const { data: usage } = useQuery({ const { data: usage } = useQuery({
queryKey: ["sms-usage", cafeId], queryKey: ["sms-usage", cafeId],
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`), queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
@@ -69,47 +80,38 @@ export function SmsScreen() {
// ── Derived state ──────────────────────────────────────────────────────────── // ── Derived state ────────────────────────────────────────────────────────────
const { chars, parts } = useMemo(() => calcSmsParts(message), [message]); const { chars, parts } = useMemo(() => calcSmsParts(message), [message]);
const usagePct = useMemo(() => { // Provider configured? Balance endpoint answers for every role; the settings
if (!usage || usage.monthlyLimit <= 0) return null; // endpoint refines it for managers (e.g. key saved but provider unreachable).
return Math.min(100, Math.round((usage.usedThisMonth / usage.monthlyLimit) * 100)); const isConfigured = settings?.isConfigured ?? balance?.isConfigured ?? false;
}, [usage]);
const usageLabel =
usage?.monthlyLimit === -1
? t("unlimited")
: `${formatNumber(usage?.usedThisMonth ?? 0, locale)} / ${formatNumber(usage?.monthlyLimit ?? 0, locale)}`;
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
<div className="mx-auto max-w-2xl space-y-4"> <div className="mx-auto max-w-2xl space-y-4">
<h2 className="text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("byoHint")}</p>
{/* ── Provider settings (Owner/Manager) ────────────────────────────────── */}
{canManage ? (
<ProviderSettingsCard cafeId={cafeId} settings={settings} />
) : null}
{/* ── Status row ──────────────────────────────────────────────────────── */} {/* ── Status row ──────────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3">
{/* Usage */} {/* Usage this month (informational — your provider account is the only cap) */}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
{t("usage")} {t("usage")}
</p> </p>
<p className="text-lg font-bold tabular-nums text-foreground">{usageLabel}</p> <p className="text-lg font-bold tabular-nums text-foreground">
{usagePct !== null && ( {formatNumber(usage?.usedThisMonth ?? 0, locale)}
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted"> </p>
<div
className={cn(
"h-full rounded-full transition-all",
usagePct >= 90 ? "bg-destructive" : "bg-primary"
)}
style={{ width: `${usagePct}%` }}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Balance */} {/* Balance of the café's own provider account */}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
@@ -125,23 +127,17 @@ export function SmsScreen() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Sender */}
<Card className="hidden sm:block">
<CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Users className="h-3.5 w-3.5" />
{t("sender")}
</p>
<p className="text-lg font-bold tabular-nums tracking-wider text-foreground" dir="ltr">
90005671
</p>
</CardContent>
</Card>
</div> </div>
{/* ── Not configured callout ───────────────────────────────────────────── */}
{!isConfigured ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{canManage ? t("notConfiguredOwner") : t("notConfiguredStaff")}
</div>
) : null}
{/* ── Campaign form ────────────────────────────────────────────────────── */} {/* ── Campaign form ────────────────────────────────────────────────────── */}
<Card> <Card className={cn(!isConfigured && "pointer-events-none opacity-50")}>
<CardContent className="space-y-4 pt-6"> <CardContent className="space-y-4 pt-6">
{/* Target group */} {/* Target group */}
<LabeledField label={t("targetGroup")} htmlFor="sms-target"> <LabeledField label={t("targetGroup")} htmlFor="sms-target">
@@ -201,7 +197,7 @@ export function SmsScreen() {
{/* Send button */} {/* Send button */}
<Button <Button
className="w-full" className="w-full"
disabled={!message.trim() || sendCampaign.isPending} disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
onClick={() => sendCampaign.mutate()} onClick={() => sendCampaign.mutate()}
> >
{sendCampaign.isPending ? t("sending") : t("send")} {sendCampaign.isPending ? t("sending") : t("send")}
@@ -237,3 +233,96 @@ export function SmsScreen() {
</div> </div>
); );
} }
/**
* Bring-your-own-provider credentials: the café's Kavenegar API key + sender
* line. The platform does not sell SMS — every campaign goes through and is
* billed to the café's own provider account.
*/
function ProviderSettingsCard({
cafeId,
settings,
}: {
cafeId: string;
settings?: SmsSettings;
}) {
const t = useTranslations("sms.settings");
const queryClient = useQueryClient();
const [apiKey, setApiKey] = useState("");
const [sender, setSender] = useState<string | null>(null);
const senderValue = sender ?? settings?.senderNumber ?? "";
const save = useMutation({
mutationFn: () =>
apiPut<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`, {
// Empty key field = keep the existing stored key.
apiKey: apiKey.trim() || null,
senderNumber: senderValue.trim(),
}),
onSuccess: () => {
notify.success(t("saved"));
setApiKey("");
void queryClient.invalidateQueries({ queryKey: ["sms-settings", cafeId] });
void queryClient.invalidateQueries({ queryKey: ["sms-balance", cafeId] });
},
onError: (err) => notifyError(err, t("saveFailed")),
});
const canSave =
senderValue.trim().length > 0 && (apiKey.trim().length > 0 || !!settings?.isConfigured);
return (
<Card className="border-primary/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4 text-primary" aria-hidden />
{t("title")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t("hint")}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("apiKey")} htmlFor="sms-api-key">
<div className="relative">
<KeyRound className="pointer-events-none absolute start-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
id="sms-api-key"
type="password"
autoComplete="off"
dir="ltr"
className="ps-9"
placeholder={settings?.apiKeyMasked ?? t("apiKeyPlaceholder")}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</LabeledField>
<LabeledField label={t("senderNumber")} htmlFor="sms-sender">
<Input
id="sms-sender"
inputMode="numeric"
dir="ltr"
placeholder={t("senderPlaceholder")}
value={senderValue}
onChange={(e) => setSender(e.target.value)}
/>
</LabeledField>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{settings?.isConfigured ? t("configured") : t("notConfigured")}
</p>
<Button
size="sm"
disabled={!canSave || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? t("saving") : t("save")}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -37,8 +37,6 @@ type BillingStatus = {
ordersDailyLimit: number | null; ordersDailyLimit: number | null;
customersCount: number; customersCount: number;
customersLimit: number | null; customersLimit: number | null;
smsUsedThisMonth: number;
smsMonthlyLimit: number;
menu3dEnabled: boolean; menu3dEnabled: boolean;
discoverProfileEnabled: boolean; discoverProfileEnabled: boolean;
isPlanExpired: boolean; isPlanExpired: boolean;
@@ -164,11 +162,6 @@ export function SubscriptionScreen() {
{status.customersLimit != null && {status.customersLimit != null &&
` / ${formatNumber(status.customersLimit)}`} ` / ${formatNumber(status.customersLimit)}`}
</li> </li>
<li>
{t("smsUsage")}: {formatNumber(status.smsUsedThisMonth)}
{status.smsMonthlyLimit >= 0 &&
` / ${formatNumber(status.smsMonthlyLimit)}`}
</li>
<li> <li>
{t("featureMenu3d")}:{" "} {t("featureMenu3d")}:{" "}
{status.menu3dEnabled ? t("featureOn") : t("featureOff")} {status.menu3dEnabled ? t("featureOn") : t("featureOff")}
+7
View File
@@ -202,6 +202,13 @@ export interface SmsBalance {
isConfigured: boolean; isConfigured: boolean;
} }
/** Café's own SMS provider settings (bring-your-own-provider; key comes back masked). */
export interface SmsSettings {
isConfigured: boolean;
apiKeyMasked?: string | null;
senderNumber?: string | null;
}
export interface Table { export interface Table {
id: string; id: string;
branchId?: string; branchId?: string;