diff --git a/src/Meezi.API/Controllers/SmsController.cs b/src/Meezi.API/Controllers/SmsController.cs index 6d43313..a765bb3 100644 --- a/src/Meezi.API/Controllers/SmsController.cs +++ b/src/Meezi.API/Controllers/SmsController.cs @@ -7,33 +7,64 @@ using Meezi.Shared; namespace Meezi.API.Controllers; +/// +/// Marketing SMS — bring-your-own-provider. Each café configures its OWN +/// Kavenegar API key + sender line; the platform does not sell SMS. +/// [Route("api/cafes/{cafeId}/sms")] public class SmsController : CafeApiControllerBase { private readonly ISmsMarketingService _smsMarketingService; - private readonly ISmsService _smsService; private readonly IValidator _campaignValidator; public SmsController( ISmsMarketingService smsMarketingService, - ISmsService smsService, IValidator campaignValidator) { _smsMarketingService = smsMarketingService; - _smsService = smsService; _campaignValidator = campaignValidator; } + [HttpGet("settings")] + public async Task 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(true, data)); + } + + [HttpPut("settings")] + public async Task 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(false, null, new ApiError(code, message!))), + _ => BadRequest(new ApiResponse(false, null, new ApiError(code!, message!))) + }; + } + + return Ok(new ApiResponse(true, data)); + } + [HttpGet("balance")] public async Task 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); - + var dto = await _smsMarketingService.GetBalanceAsync(cafeId, cancellationToken); return Ok(new ApiResponse(true, dto)); } @@ -41,10 +72,8 @@ public class SmsController : CafeApiControllerBase public async Task GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; - if (tenant.PlanTier is null) - return BadRequest(new ApiResponse(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(true, data)); } @@ -56,20 +85,18 @@ public class SmsController : CafeApiControllerBase CancellationToken cancellationToken) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; - if (tenant.PlanTier is null) - return BadRequest(new ApiResponse(false, null, new ApiError("INVALID", "Plan tier missing."))); var validation = await _campaignValidator.ValidateAsync(request, cancellationToken); if (!validation.IsValid) return BadRequest(ValidationError(validation)); var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync( - cafeId, tenant.PlanTier.Value, request, cancellationToken); + cafeId, request, cancellationToken); if (!success) { return code switch { - "PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden, + "SMS_NOT_CONFIGURED" => BadRequest( new ApiResponse(false, null, new ApiError(code, message!))), "NOT_FOUND" => NotFound(new ApiResponse(false, null, new ApiError(code, message!))), _ => BadRequest(new ApiResponse(false, null, new ApiError(code!, message!))) diff --git a/src/Meezi.API/Models/Billing/BillingDtos.cs b/src/Meezi.API/Models/Billing/BillingDtos.cs index b0aa313..52a56b8 100644 --- a/src/Meezi.API/Models/Billing/BillingDtos.cs +++ b/src/Meezi.API/Models/Billing/BillingDtos.cs @@ -15,8 +15,6 @@ public record BillingStatusDto( int? OrdersDailyLimit, int CustomersCount, int? CustomersLimit, - int SmsUsedThisMonth, - int SmsMonthlyLimit, bool Menu3dEnabled, bool MenuAi3dEnabled, int MenuAi3dUsedThisMonth, diff --git a/src/Meezi.API/Models/Crm/SmsDtos.cs b/src/Meezi.API/Models/Crm/SmsDtos.cs index 6787a4d..965f355 100644 --- a/src/Meezi.API/Models/Crm/SmsDtos.cs +++ b/src/Meezi.API/Models/Crm/SmsDtos.cs @@ -13,3 +13,11 @@ public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month); /// Kavenegar account credit balance returned to the dashboard. public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured); + +/// +/// 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. +/// +public record SmsSettingsDto(bool IsConfigured, string? ApiKeyMasked, string? SenderNumber); + +public record UpdateSmsSettingsRequest(string? ApiKey, string? SenderNumber); diff --git a/src/Meezi.API/Services/BillingService.cs b/src/Meezi.API/Services/BillingService.cs index 3ebe0a7..7ba93c3 100644 --- a/src/Meezi.API/Services/BillingService.cs +++ b/src/Meezi.API/Services/BillingService.cs @@ -379,12 +379,7 @@ public class BillingService : IBillingService var maxOrders = PlanLimits.MaxOrdersPerDay(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 smsUsed = await redis.StringGetAsync(monthKey); - var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0; var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync( cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken); @@ -406,8 +401,6 @@ public class BillingService : IBillingService maxOrders == int.MaxValue ? null : maxOrders, customersCount, maxCustomers == int.MaxValue ? null : maxCustomers, - smsUsedCount, - maxSms == int.MaxValue ? -1 : maxSms, menu3d, menuAi3d, ai3dUsedCount, diff --git a/src/Meezi.API/Services/PlanLimitChecker.cs b/src/Meezi.API/Services/PlanLimitChecker.cs index afd2011..16622a6 100644 --- a/src/Meezi.API/Services/PlanLimitChecker.cs +++ b/src/Meezi.API/Services/PlanLimitChecker.cs @@ -114,26 +114,9 @@ public class PlanLimitChecker : IPlanLimitChecker } } - var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign"; - if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) || - path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase)) - { - 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."); - } + // NOTE: SMS is deliberately NOT plan-gated — marketing SMS is + // bring-your-own-provider (the café's own API key + sender line), so the + // café's provider account is the only limit. return (true, null, null); } diff --git a/src/Meezi.API/Services/SmsMarketingService.cs b/src/Meezi.API/Services/SmsMarketingService.cs index bd81975..d30b4e8 100644 --- a/src/Meezi.API/Services/SmsMarketingService.cs +++ b/src/Meezi.API/Services/SmsMarketingService.cs @@ -1,6 +1,4 @@ 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; @@ -11,14 +9,25 @@ namespace Meezi.API.Services; public interface ISmsMarketingService { - Task GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default); + Task GetUsageAsync(string cafeId, CancellationToken cancellationToken = default); + Task GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default); + Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync( + string cafeId, + UpdateSmsSettingsRequest request, + CancellationToken cancellationToken = default); + Task GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default); Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync( string cafeId, - PlanTier planTier, SendSmsCampaignRequest request, CancellationToken cancellationToken = default); } +/// +/// 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.) +/// public class SmsMarketingService : ISmsMarketingService { private readonly AppDbContext _db; @@ -35,40 +44,111 @@ public class SmsMarketingService : ISmsMarketingService _redis = redis; } - public async Task GetUsageAsync( - string cafeId, - PlanTier planTier, - CancellationToken cancellationToken = default) + public async Task GetUsageAsync(string cafeId, 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); + // -1 = no platform limit; the café's own provider account is the only cap. + return new SmsUsageDto(used, -1, month); + } + + public async Task 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 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( 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 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) || 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); 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 result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken); + var result = await _smsService.SendBulkWithCredentialsAsync( + cafe.SmsApiKey, cafe.SmsSenderNumber, phones, request.Message, cancellationToken); if (result.SentCount > 0) + { + var month = DateTime.UtcNow.ToString("yyyy-MM"); await IncrementUsageAsync(cafeId, month, result.SentCount); + } 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); } + private static string MaskApiKey(string apiKey) => + apiKey.Length <= 4 ? "****" : $"****{apiKey[^4..]}"; + private async Task GetUsedCountAsync(string cafeId, string month) { var redis = _redis.GetDatabase(); diff --git a/src/Meezi.Admin.API/Models/IntegrationDtos.cs b/src/Meezi.Admin.API/Models/IntegrationDtos.cs index ddfc5ed..ad13f2e 100644 --- a/src/Meezi.Admin.API/Models/IntegrationDtos.cs +++ b/src/Meezi.Admin.API/Models/IntegrationDtos.cs @@ -55,8 +55,11 @@ public record PlatformIntegrationsDto( public record UpdatePlatformIntegrationsRequest( string ActivePaymentGateway, IReadOnlyList PaymentGateways, - UpdateKavenegarRequest Kavenegar, - UpdateAiIntegrationsRequest Ai); + // Optional: the admin UI no longer manages SMS (marketing SMS is + // 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( bool IsEnabled, diff --git a/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs b/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs index fc3c4d7..210aeab 100644 --- a/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs +++ b/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs @@ -107,23 +107,32 @@ public class PlatformIntegrationService : IPlatformIntegrationService await SaveCredentialsAsync(meta.Prefix, gw.Id, gw.Credentials, ct); } - await UpsertAsync(KeyKavenegarEnabled, request.Kavenegar.IsEnabled ? "true" : "false", "integrations", "فعال کاوه‌نگار", ct); - await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct); - if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey)) - await UpsertAsync(KeyKavenegarApi, request.Kavenegar.ApiKey.Trim(), "integrations", "API Key کاوه‌نگار", ct); - if (!string.IsNullOrWhiteSpace(request.Kavenegar.SenderNumber)) - await UpsertAsync(KeyKavenegarSender, request.Kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوه‌نگار", ct); + // SMS (Kavenegar) is no longer managed from the admin UI — marketing SMS is + // bring-your-own-provider per café, and the platform OTP credentials live in + // env/appsettings (or the previously-stored DB values, left untouched here). + if (request.Kavenegar is { } kavenegar) + { + 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); - 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); - if (!string.IsNullOrWhiteSpace(request.Ai.OpenAi.ApiKey) && !IsMaskedPlaceholder(request.Ai.OpenAi.ApiKey)) - await UpsertAsync(PlatformIntegrationKeys.OpenAiApiKey, request.Ai.OpenAi.ApiKey.Trim(), "integrations", "API Key OpenAI", ct); + if (request.Ai is { } ai) + { + await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct); + await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(ai.OpenAi.Model) ? "gpt-4o-mini" : ai.OpenAi.Model.Trim(), "integrations", "مدل 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.MeshyMenu3dEnabled, request.Ai.Meshy.Menu3dEnabled ? "true" : "false", "integrations", "ساخت ۳D منو با Meshy", ct); - if (!string.IsNullOrWhiteSpace(request.Ai.Meshy.ApiKey) && !IsMaskedPlaceholder(request.Ai.Meshy.ApiKey)) - await UpsertAsync(PlatformIntegrationKeys.MeshyApiKey, request.Ai.Meshy.ApiKey.Trim(), "integrations", "API Key Meshy", ct); + await UpsertAsync(PlatformIntegrationKeys.MeshyEnabled, ai.Meshy.IsEnabled ? "true" : "false", "integrations", "فعال Meshy", ct); + await UpsertAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, ai.Meshy.Menu3dEnabled ? "true" : "false", "integrations", "ساخت ۳D منو با Meshy", ct); + if (!string.IsNullOrWhiteSpace(ai.Meshy.ApiKey) && !IsMaskedPlaceholder(ai.Meshy.ApiKey)) + await UpsertAsync(PlatformIntegrationKeys.MeshyApiKey, ai.Meshy.ApiKey.Trim(), "integrations", "API Key Meshy", ct); + } await _db.SaveChangesAsync(ct); _catalog.InvalidateCache(); diff --git a/src/Meezi.Core/Entities/Cafe.cs b/src/Meezi.Core/Entities/Cafe.cs index 4795cf5..69acfef 100644 --- a/src/Meezi.Core/Entities/Cafe.cs +++ b/src/Meezi.Core/Entities/Cafe.cs @@ -48,6 +48,12 @@ public class Cafe : BaseEntity public decimal DefaultTaxRate { get; set; } = 9m; public bool AllowBranchTaxOverride { get; set; } + /// 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é. + public string? SmsApiKey { get; set; } + /// Café's own SMS sender line number (e.g. 10004346). + public string? SmsSenderNumber { get; set; } + public ICollection Branches { get; set; } = []; public ICollection Tables { get; set; } = []; public ICollection Employees { get; set; } = []; diff --git a/src/Meezi.Core/Interfaces/ISmsService.cs b/src/Meezi.Core/Interfaces/ISmsService.cs index 2f057c8..13e8581 100644 --- a/src/Meezi.Core/Interfaces/ISmsService.cs +++ b/src/Meezi.Core/Interfaces/ISmsService.cs @@ -20,4 +20,19 @@ public interface ISmsService /// Returns credit balance from the Kavenegar account, or null if not configured. Task GetAccountInfoAsync(CancellationToken cancellationToken = default); + + /// + /// 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. + /// + Task SendBulkWithCredentialsAsync( + string apiKey, + string senderNumber, + IReadOnlyList phones, + string message, + CancellationToken cancellationToken = default); + + /// Credit balance for an EXPLICIT API key (a café's own account), or null on failure. + Task GetAccountInfoAsync(string apiKey, CancellationToken cancellationToken = default); } diff --git a/src/Meezi.Infrastructure/Data/DesignTimeDbContextFactory.cs b/src/Meezi.Infrastructure/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..ef2ddf8 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Meezi.Infrastructure.Data; + +/// +/// 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. +/// +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .UseNpgsql(conn) + .Options; + + return new AppDbContext(options); + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.Designer.cs new file mode 100644 index 0000000..0d1181f --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.Designer.cs @@ -0,0 +1,3419 @@ +// +using System; +using Meezi.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260612051150_AddCafeSmsCredentials")] + partial class AddCafeSmsCredentials + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Meezi.Core.Entities.Attendance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClockIn") + .HasColumnType("timestamp with time zone"); + + b.Property("ClockOut") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "Date") + .IsUnique(); + + b.ToTable("Attendances"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ActorId") + .HasColumnType("text"); + + b.Property("ActorName") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "BranchId"); + + b.HasIndex("CafeId", "Category"); + + b.HasIndex("CafeId", "CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccentColor") + .HasColumnType("text"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AutoCutEnabled") + .HasColumnType("boolean"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KitchenPrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("KitchenPrinterPort") + .HasColumnType("integer"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PaperWidthMm") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PosDeviceIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PosDevicePort") + .HasColumnType("integer"); + + b.Property("ReceiptFooter") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptHeader") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ReceiptPrinterPort") + .HasColumnType("integer"); + + b.Property("ScheduledPermanentDeleteAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TaxRate") + .HasColumnType("numeric"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WelcomeText") + .HasColumnType("text"); + + b.Property("WifiPassword") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "IsActive"); + + b.ToTable("Branches"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.BranchMenuItemOverride", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PriceOverride") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SortOrderOverride") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("BranchId", "MenuItemId") + .IsUnique(); + + b.ToTable("BranchMenuItemOverrides"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Cafe", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AllowBranchTaxOverride") + .HasColumnType("boolean"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultTaxRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DigikalaVendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DiscoverBadgesJson") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DiscoverProfileJson") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("GalleryJson") + .HasColumnType("text"); + + b.Property("InstagramHandle") + .HasColumnType("text"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanTier") + .HasColumnType("integer"); + + b.Property("PreferredLanguage") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowOnKoja") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SmsApiKey") + .HasColumnType("text"); + + b.Property("SmsSenderNumber") + .HasColumnType("text"); + + b.Property("SnappfoodVendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Tap30VendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ThemeJson") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("WebsiteUrl") + .HasColumnType("text"); + + b.Property("WorkingHoursJson") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Cafes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeFeatureOverride", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FeatureKey") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "FeatureKey") + .IsUnique(); + + b.ToTable("CafeFeatureOverrides"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeNotification", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TableNumber") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "IsRead", "CreatedAt"); + + b.ToTable("CafeNotifications"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorPhone") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("OwnerRepliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerReply") + .HasColumnType("text"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "CreatedAt"); + + b.ToTable("CafeReviews"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReviewPhoto", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("ReviewId"); + + b.ToTable("CafeReviewPhotos"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CashTransaction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("ShiftId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("CafeId", "BranchId"); + + b.ToTable("CashTransactions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.ConsumerAccount", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("ConsumerAccounts"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxDiscount") + .HasColumnType("numeric"); + + b.Property("MinOrderAmount") + .HasColumnType("numeric"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetGroup") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UsageLimit") + .HasColumnType("integer"); + + b.Property("UsedCount") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BirthDateJalali") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .HasColumnType("integer"); + + b.Property("LoyaltyPoints") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NationalId") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferredBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Phone"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DailyReport", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AvgOrderValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CashRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NetIncome") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TopProducts") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TotalExpenses") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalOrders") + .HasColumnType("integer"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalVoids") + .HasColumnType("integer"); + + b.Property("VoidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "BranchId", "Date") + .IsUnique(); + + b.ToTable("DailyReports"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DeliveryCommissionRate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .HasColumnType("integer"); + + b.Property("RatePercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Platform") + .IsUnique(); + + b.ToTable("DeliveryCommissionRates"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DemoRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("BranchCount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BusinessName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ContactedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("website"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("DemoRequests"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BaseSalary") + .HasColumnType("numeric"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NationalId") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "Phone") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "BranchId"); + + b.HasIndex("EmployeeId", "BranchId") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("EmployeeBranchRoles"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BaseSalary") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deductions") + .HasColumnType("numeric"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("MonthYear") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetSalary") + .HasColumnType("numeric"); + + b.Property("OvertimePay") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "MonthYear") + .IsUnique(); + + b.ToTable("EmployeeSalaries"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSchedule", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "DayOfWeek") + .IsUnique(); + + b.ToTable("EmployeeSchedules", (string)null); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Expense", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("CafeId", "BranchId", "CreatedAt"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Scope", "Key") + .IsUnique(); + + b.ToTable("IdempotencyRecords"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LowStockWarningPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParLevel") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("QuantityOnHand") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReorderLevel") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UnitCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.ToTable("Ingredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PrinterPort") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "SortOrder"); + + b.ToTable("KitchenStations"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.LeaveRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Reason") + .HasColumnType("text"); + + b.Property("ReviewedBy") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("LeaveRequests"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "ContentHash"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountPercent") + .HasColumnType("numeric"); + + b.Property("Icon") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IconPresetId") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("IconStyle") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KitchenStationId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TaxId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("KitchenStationId"); + + b.HasIndex("TaxId"); + + b.ToTable("MenuCategories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DiscountPercent") + .HasColumnType("numeric"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Model3dUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("VideoUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("CategoryId"); + + b.ToTable("MenuItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IngredientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("QuantityPerUnit") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("CafeId", "MenuItemId", "IngredientId") + .IsUnique(); + + b.ToTable("MenuItemIngredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CancelReason") + .HasColumnType("text"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByEmployeeId") + .HasColumnType("text"); + + b.Property("CouponId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeliveryMetaJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeliveryPlatform") + .HasColumnType("integer"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("DisplayNumber") + .HasColumnType("integer"); + + b.Property("EmployeeId") + .HasColumnType("text"); + + b.Property("ExternalOrderId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GuestName") + .HasColumnType("text"); + + b.Property("GuestPhone") + .HasColumnType("text"); + + b.Property("GuestTrackingToken") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OrderType") + .HasColumnType("integer"); + + b.Property("PlatformCommission") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ReservationId") + .HasColumnType("text"); + + b.Property("SnappfoodOrderId") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TableId") + .HasColumnType("text"); + + b.Property("TaxTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CouponId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("GuestTrackingToken"); + + b.HasIndex("ReservationId"); + + b.HasIndex("TableId"); + + b.HasIndex("CafeId", "DisplayNumber") + .IsUnique(); + + b.HasIndex("CafeId", "DeliveryPlatform", "ExternalOrderId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsVoided") + .HasColumnType("boolean"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("VoidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoidedByUserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Payment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reference") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformFeature", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameEn") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayNameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsEnabledGlobally") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("ModuleGroup") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformFeatures"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformPlanDefinition", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameEn") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayNameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FeaturesJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBillableOnline") + .HasColumnType("boolean"); + + b.Property("LimitsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MonthlyPriceToman") + .HasPrecision(18) + .HasColumnType("numeric(18,0)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Tier") + .IsUnique(); + + b.ToTable("PlatformPlanDefinitions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformSetting", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionFa") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformSettings"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConsumerAccountId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("City"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("PushDevices"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerLabel") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OrderId") + .HasColumnType("text"); + + b.Property("ServiceDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("OrderId"); + + b.HasIndex("CafeId", "BranchId", "ServiceDate", "Number") + .IsUnique(); + + b.ToTable("QueueTickets"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedByUserId") + .HasColumnType("text"); + + b.Property("ClosingCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discrepancy") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExpectedCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OpenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OpenedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OpeningCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("ClosedByUserId"); + + b.HasIndex("OpenedByUserId"); + + b.HasIndex("BranchId", "Status"); + + b.ToTable("RegisterShifts", (string)null); + }); + + modelBuilder.Entity("Meezi.Core.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ExpenseId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IngredientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalCostToman") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("CafeId", "OrderId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SubscriptionPayment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AmountRials") + .HasColumnType("bigint"); + + b.Property("AmountToman") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Authority") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("Months") + .HasColumnType("integer"); + + b.Property("PlanTier") + .HasColumnType("integer"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("RefId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Authority"); + + b.HasIndex("CafeId"); + + b.ToTable("SubscriptionPayments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssignedAdminId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByEmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByEmployeeId"); + + b.HasIndex("CafeId", "Status", "UpdatedAt"); + + b.ToTable("SupportTickets"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicketMessage", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderKind") + .HasColumnType("integer"); + + b.Property("TicketId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("SupportTicketMessages"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SystemAdmin", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("SystemAdmins"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Floor") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsCleaning") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QrCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("VideoUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("QrCode") + .IsUnique(); + + b.HasIndex("SectionId"); + + b.HasIndex("BranchId", "SectionId", "SortOrder"); + + b.ToTable("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableReservation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GuestName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GuestPhone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PartySize") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TableId") + .HasColumnType("text"); + + b.Property("Time") + .HasColumnType("time without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("TableId"); + + b.HasIndex("CafeId", "Date", "Time"); + + b.ToTable("TableReservations"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("BranchId", "Name"); + + b.ToTable("TableSections"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompound") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.ToTable("Taxes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebhookLog", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CafeId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExternalOrderId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MeeziOrderId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Platform") + .HasColumnType("integer"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignatureHeader") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("Platform", "CreatedAt"); + + b.ToTable("WebhookLogs"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteBlogPost", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryFa") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentFa") + .IsRequired() + .HasColumnType("text"); + + b.Property("CoverImage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExcerptEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExcerptFa") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasDefaultValue("[]"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TitleFa") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("IsPublished", "PublishedAt"); + + b.ToTable("WebsiteBlogPosts"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteComment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AuthorEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("PostSlug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("PostSlug", "IsApproved", "CreatedAt"); + + b.ToTable("WebsiteComments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Attendance", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Attendances") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", null) + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Branches") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.BranchMenuItemOverride", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("BranchOverrides") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("MenuItem"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeFeatureOverride", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", null) + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Reviews") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReviewPhoto", b => + { + b.HasOne("Meezi.Core.Entities.CafeReview", "Review") + .WithMany("Photos") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Review"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CashTransaction", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Shift", "Shift") + .WithMany("Transactions") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Coupons") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Customers") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DailyReport", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DeliveryCommissionRate", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Staff") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Employees") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("StaffRoles") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("BranchRoles") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Salaries") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSchedule", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Schedules") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Expense", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Shift", "Shift") + .WithMany() + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Ingredients") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.LeaveRequest", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("LeaveRequests") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("MenuCategories") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation") + .WithMany("Categories") + .HasForeignKey("KitchenStationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Tax", "Tax") + .WithMany("MenuCategories") + .HasForeignKey("TaxId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Cafe"); + + b.Navigation("KitchenStation"); + + b.Navigation("Tax"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("MenuItems") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuCategory", "Category") + .WithMany("MenuItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => + { + b.HasOne("Meezi.Core.Entities.Ingredient", "Ingredient") + .WithMany("MenuItemRecipes") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("RecipeIngredients") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("MenuItem"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Orders") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Orders") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Coupon", "Coupon") + .WithMany("Orders") + .HasForeignKey("CouponId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Customer", "Customer") + .WithMany("Orders") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Orders") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.TableReservation", "Reservation") + .WithMany() + .HasForeignKey("ReservationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Table", "Table") + .WithMany("Orders") + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Coupon"); + + b.Navigation("Customer"); + + b.Navigation("Employee"); + + b.Navigation("Reservation"); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.OrderItem", b => + { + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("OrderItems") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Payment", b => + { + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany("Payments") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "ClosedBy") + .WithMany() + .HasForeignKey("ClosedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Employee", "OpenedBy") + .WithMany() + .HasForeignKey("OpenedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("ClosedBy"); + + b.Navigation("OpenedBy"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.StockMovement", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Ingredient", "Ingredient") + .WithMany("Movements") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("Ingredient"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SubscriptionPayment", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("SubscriptionPayments") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "CreatedByEmployee") + .WithMany() + .HasForeignKey("CreatedByEmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("CreatedByEmployee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicketMessage", b => + { + b.HasOne("Meezi.Core.Entities.SupportTicket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Tables") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Tables") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.TableSection", "Section") + .WithMany("Tables") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableReservation", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Table", "Table") + .WithMany() + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Cafe"); + + b.Navigation("Customer"); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Sections") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Taxes") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebhookLog", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteComment", b => + { + b.HasOne("Meezi.Core.Entities.WebsiteBlogPost", "Post") + .WithMany("Comments") + .HasForeignKey("PostSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.Navigation("Orders"); + + b.Navigation("Sections"); + + b.Navigation("Staff"); + + b.Navigation("StaffRoles"); + + b.Navigation("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Cafe", b => + { + b.Navigation("Branches"); + + b.Navigation("Coupons"); + + b.Navigation("Customers"); + + b.Navigation("Employees"); + + b.Navigation("Ingredients"); + + b.Navigation("MenuCategories"); + + b.Navigation("MenuItems"); + + b.Navigation("Orders"); + + b.Navigation("Reviews"); + + b.Navigation("SubscriptionPayments"); + + b.Navigation("Tables"); + + b.Navigation("Taxes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.Navigation("Photos"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.Navigation("Attendances"); + + b.Navigation("BranchRoles"); + + b.Navigation("LeaveRequests"); + + b.Navigation("Orders"); + + b.Navigation("Salaries"); + + b.Navigation("Schedules"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.Navigation("MenuItemRecipes"); + + b.Navigation("Movements"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.Navigation("MenuItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.Navigation("BranchOverrides"); + + b.Navigation("OrderItems"); + + b.Navigation("RecipeIngredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.Navigation("Items"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.Navigation("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.Navigation("MenuCategories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteBlogPost", b => + { + b.Navigation("Comments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.cs b/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.cs new file mode 100644 index 0000000..9d47aa8 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260612051150_AddCafeSmsCredentials.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddCafeSmsCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SmsApiKey", + table: "Cafes", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "SmsSenderNumber", + table: "Cafes", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SmsApiKey", + table: "Cafes"); + + migrationBuilder.DropColumn( + name: "SmsSenderNumber", + table: "Cafes"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 103dbb6..2dec06a 100644 --- a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -370,6 +370,12 @@ namespace Meezi.Infrastructure.Data.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("SmsApiKey") + .HasColumnType("text"); + + b.Property("SmsSenderNumber") + .HasColumnType("text"); + b.Property("SnappfoodVendorId") .HasMaxLength(100) .HasColumnType("character varying(100)"); diff --git a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs index d183a4e..d140637 100644 --- a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs +++ b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs @@ -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 GetAccountInfoAsync(string apiKey, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(apiKey)) return null; try { @@ -140,6 +146,46 @@ public class KavenegarSmsService : ISmsService } } + /// + /// 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. + /// + public async Task SendBulkWithCredentialsAsync( + string apiKey, + string senderNumber, + IReadOnlyList 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 ──────────────────────────────────────────────────────────── /// diff --git a/web/admin/messages/fa.json b/web/admin/messages/fa.json index c62bb8b..6067fa8 100644 --- a/web/admin/messages/fa.json +++ b/web/admin/messages/fa.json @@ -1020,7 +1020,7 @@ "title": "مدیریت سامانه", "dashboard": "داشبورد", "plans": "اشتراک و قیمت", - "integrations": "درگاه و پیامک", + "integrations": "درگاه پرداخت و AI", "notifications": "اعلان‌ها", "settings": "تنظیمات اپ", "features": "قابلیت‌ها", diff --git a/web/admin/src/components/admin/admin-screens.tsx b/web/admin/src/components/admin/admin-screens.tsx index 3c9883f..b4936f1 100644 --- a/web/admin/src/components/admin/admin-screens.tsx +++ b/web/admin/src/components/admin/admin-screens.tsx @@ -143,7 +143,7 @@ const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [ { key: "maxMenuItems", label: "maxItems" }, { key: "maxCustomers", label: "maxCustomers" }, { key: "maxReportHistoryDays", label: "maxReportDays" }, - { key: "maxSmsPerMonth", label: "maxSms" }, + // No SMS limit — marketing SMS is bring-your-own-provider per café. { key: "maxMenuAi3dPerMonth", label: "maxAi3d" }, ]; @@ -701,11 +701,6 @@ export function AdminIntegrationsScreen() { hasStoredClientSecret: prev?.hasStoredClientSecret ?? false, ...patch, }); - const [kavenegar, setKavenegar] = useState({ - isEnabled: true, - apiKey: "", - otpTemplate: "verify", - }); const [openAi, setOpenAi] = useState({ isEnabled: false, apiKey: "", @@ -722,11 +717,6 @@ export function AdminIntegrationsScreen() { if (!data) return; setActiveGateway(data.activePaymentGateway); setGateways(data.paymentGateways.map((g) => ({ ...g }))); - setKavenegar({ - isEnabled: data.kavenegar.isEnabled, - apiKey: data.kavenegar.apiKey ?? "", - otpTemplate: data.kavenegar.otpTemplate, - }); setOpenAi({ isEnabled: data.ai.openAi.isEnabled, apiKey: data.ai.openAi.apiKey ?? "", @@ -770,7 +760,7 @@ export function AdminIntegrationsScreen() { } : undefined, })), - kavenegar, + // SMS is bring-your-own-provider per café — no platform SMS config here. ai: { openAi, meshy }, }), onSuccess: () => { @@ -998,39 +988,6 @@ export function AdminIntegrationsScreen() { ))} -
-

- {t("kavenegarTitle")} -

- -
- setKavenegar((k) => ({ ...k, isEnabled: v }))} - /> - {t("enabled")} -
- - -
-
-

{t("aiTitle")} diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 6868ef8..5d6a591 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -481,10 +481,36 @@ "targetGroup": "المجموعة المستهدفة", "allCustomers": "كل العملاء", "send": "إرسال", - "usage": "الاستخدام هذا الشهر", + "usage": "المُرسَل هذا الشهر", "unlimited": "غير محدود", "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": { "title": "التقارير والتحليلات", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 4f15bd0..88543b6 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -500,19 +500,36 @@ "targetGroup": "Target group", "allCustomers": "All customers", "send": "Send", - "usage": "Usage this month", + "usage": "Sent this month", "unlimited": "Unlimited", "sent": "Sent", "failed": "Failed", "charCount": "{count} chars", "smsPartsHint": "{parts} SMS", - "balance": "Account credit", + "balance": "Your account credit", "balanceAmount": "{amount} Rials", - "balanceNotConfigured": "Kavenegar not configured", + "balanceNotConfigured": "SMS service not set up", "sender": "Sender line", "recipientsCount": "{count} recipients", "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": { "title": "Reports & analytics", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 2ffc86d..0fd2e5d 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -500,19 +500,36 @@ "targetGroup": "گروه هدف", "allCustomers": "همه مشتریان", "send": "ارسال", - "usage": "مصرف این ماه", + "usage": "ارسال‌شده این ماه", "unlimited": "نامحدود", "sent": "ارسال شد", "failed": "ناموفق", "charCount": "{count} حرف", "smsPartsHint": "{parts} پیامک", - "balance": "اعتبار حساب", + "balance": "اعتبار حساب شما", "balanceAmount": "{amount} ریال", - "balanceNotConfigured": "Kavenegar پیکربندی نشده", + "balanceNotConfigured": "سرویس پیامک راه‌اندازی نشده", "sender": "خط فرستنده", "recipientsCount": "{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": { "title": "گزارش‌ها و تحلیل", @@ -1481,7 +1498,7 @@ "title": "مدیریت سامانه", "dashboard": "داشبورد", "plans": "اشتراک و قیمت", - "integrations": "درگاه و پیامک", + "integrations": "درگاه پرداخت و AI", "notifications": "اعلان‌ها", "settings": "تنظیمات اپ", "features": "قابلیت‌ها", diff --git a/web/dashboard/src/components/settings/plan-comparison.tsx b/web/dashboard/src/components/settings/plan-comparison.tsx index 368cbc4..325c898 100644 --- a/web/dashboard/src/components/settings/plan-comparison.tsx +++ b/web/dashboard/src/components/settings/plan-comparison.tsx @@ -19,13 +19,13 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; /** 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 }[] = [ { key: "maxOrdersPerDay" }, { key: "maxBranches" }, { key: "maxTerminals" }, { key: "maxTables" }, { key: "maxCustomers" }, - { key: "maxSmsPerMonth", zeroAsDash: true }, { key: "maxMenuItems" }, { key: "maxReportHistoryDays" }, { key: "maxMenuAi3dPerMonth", zeroAsDash: true }, diff --git a/web/dashboard/src/components/sms/sms-screen.tsx b/web/dashboard/src/components/sms/sms-screen.tsx index daee3e1..f52829d 100644 --- a/web/dashboard/src/components/sms/sms-screen.tsx +++ b/web/dashboard/src/components/sms/sms-screen.tsx @@ -3,17 +3,20 @@ import { useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslations, useLocale } from "next-intl"; -import { MessageSquare, Zap, Users } from "lucide-react"; -import { apiGet, apiPost } from "@/lib/api/client"; -import type { CustomerGroup, SmsCampaignResult, SmsUsage, SmsBalance } from "@/lib/api/types"; +import { KeyRound, MessageSquare, Settings2, Zap } from "lucide-react"; +import { apiGet, apiPost, apiPut } from "@/lib/api/client"; +import type { CustomerGroup, SmsCampaignResult, SmsSettings, SmsUsage, SmsBalance } from "@/lib/api/types"; import { useAuthStore } from "@/lib/stores/auth.store"; import { formatNumber } from "@/lib/format"; +import { notify, notifyError } from "@/lib/notify"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"]; +const MANAGER_ROLES = new Set(["Owner", "Manager"]); /** Kavenegar SMS character limits. */ function calcSmsParts(text: string): { chars: number; parts: number } { @@ -32,13 +35,21 @@ export function SmsScreen() { const tCrm = useTranslations("crm"); const locale = useLocale(); const cafeId = useAuthStore((s) => s.user?.cafeId); + const role = useAuthStore((s) => s.user?.role); const queryClient = useQueryClient(); + const canManage = MANAGER_ROLES.has(role ?? ""); const [message, setMessage] = useState(""); const [target, setTarget] = useState("all"); const [result, setResult] = useState(null); // ── API queries ───────────────────────────────────────────────────────────── + const { data: settings } = useQuery({ + queryKey: ["sms-settings", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/sms/settings`), + enabled: !!cafeId && canManage, + }); + const { data: usage } = useQuery({ queryKey: ["sms-usage", cafeId], queryFn: () => apiGet(`/api/cafes/${cafeId}/sms/usage`), @@ -69,47 +80,38 @@ export function SmsScreen() { // ── Derived state ──────────────────────────────────────────────────────────── const { chars, parts } = useMemo(() => calcSmsParts(message), [message]); - const usagePct = useMemo(() => { - if (!usage || usage.monthlyLimit <= 0) return null; - return Math.min(100, Math.round((usage.usedThisMonth / usage.monthlyLimit) * 100)); - }, [usage]); - - const usageLabel = - usage?.monthlyLimit === -1 - ? t("unlimited") - : `${formatNumber(usage?.usedThisMonth ?? 0, locale)} / ${formatNumber(usage?.monthlyLimit ?? 0, locale)}`; + // Provider configured? Balance endpoint answers for every role; the settings + // endpoint refines it for managers (e.g. key saved but provider unreachable). + const isConfigured = settings?.isConfigured ?? balance?.isConfigured ?? false; if (!cafeId) return null; return (

{t("title")}

+

{t("byoHint")}

+ + {/* ── Provider settings (Owner/Manager) ────────────────────────────────── */} + {canManage ? ( + + ) : null} {/* ── Status row ──────────────────────────────────────────────────────── */} -
- {/* Usage */} +
+ {/* Usage this month (informational — your provider account is the only cap) */}

{t("usage")}

-

{usageLabel}

- {usagePct !== null && ( -
-
= 90 ? "bg-destructive" : "bg-primary" - )} - style={{ width: `${usagePct}%` }} - /> -
- )} +

+ {formatNumber(usage?.usedThisMonth ?? 0, locale)} +

- {/* Balance */} + {/* Balance of the café's own provider account */}

@@ -125,23 +127,17 @@ export function SmsScreen() { )} - - {/* Sender */} - - -

- - {t("sender")} -

-

- 90005671 -

-
-
+ {/* ── Not configured callout ───────────────────────────────────────────── */} + {!isConfigured ? ( +
+ {canManage ? t("notConfiguredOwner") : t("notConfiguredStaff")} +
+ ) : null} + {/* ── Campaign form ────────────────────────────────────────────────────── */} - + {/* Target group */} @@ -201,7 +197,7 @@ export function SmsScreen() { {/* Send button */}
); } + +/** + * 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(null); + + const senderValue = sender ?? settings?.senderNumber ?? ""; + + const save = useMutation({ + mutationFn: () => + apiPut(`/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 ( + + + + + {t("title")} + +

{t("hint")}

+
+ +
+ +
+ + setApiKey(e.target.value)} + /> +
+
+ + setSender(e.target.value)} + /> + +
+
+

+ {settings?.isConfigured ? t("configured") : t("notConfigured")} +

+ +
+
+
+ ); +} diff --git a/web/dashboard/src/components/subscription/subscription-screen.tsx b/web/dashboard/src/components/subscription/subscription-screen.tsx index 75aafcc..5d3f8ca 100644 --- a/web/dashboard/src/components/subscription/subscription-screen.tsx +++ b/web/dashboard/src/components/subscription/subscription-screen.tsx @@ -37,8 +37,6 @@ type BillingStatus = { ordersDailyLimit: number | null; customersCount: number; customersLimit: number | null; - smsUsedThisMonth: number; - smsMonthlyLimit: number; menu3dEnabled: boolean; discoverProfileEnabled: boolean; isPlanExpired: boolean; @@ -164,11 +162,6 @@ export function SubscriptionScreen() { {status.customersLimit != null && ` / ${formatNumber(status.customersLimit)}`} -
  • - {t("smsUsage")}: {formatNumber(status.smsUsedThisMonth)} - {status.smsMonthlyLimit >= 0 && - ` / ${formatNumber(status.smsMonthlyLimit)}`} -
  • {t("featureMenu3d")}:{" "} {status.menu3dEnabled ? t("featureOn") : t("featureOff")} diff --git a/web/dashboard/src/lib/api/types.ts b/web/dashboard/src/lib/api/types.ts index 42f5d22..a0c6061 100644 --- a/web/dashboard/src/lib/api/types.ts +++ b/web/dashboard/src/lib/api/types.ts @@ -202,6 +202,13 @@ export interface SmsBalance { 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 { id: string; branchId?: string;