feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
using Meezi.Admin.API.Models;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IAdminAuthService
|
||||
{
|
||||
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class AdminAuthService : IAdminAuthService
|
||||
{
|
||||
private const int OtpTtlSeconds = 300;
|
||||
private const int DefaultMaxOtpAttemptsPerHour = 5;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IAdminJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AdminAuthService> _logger;
|
||||
|
||||
public AdminAuthService(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
ISmsService smsService,
|
||||
IAdminJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
IConfiguration configuration,
|
||||
ILogger<AdminAuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_smsService = smsService;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var admin = await _db.SystemAdmins
|
||||
.FirstOrDefaultAsync(a => a.Phone == phone && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (admin is null)
|
||||
return (false, null, "NOT_FOUND", "No system admin account for this phone.");
|
||||
|
||||
var redis = _redis.GetDatabase();
|
||||
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
|
||||
var attemptsKey = $"otp:admin:{phone}";
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var attempts = await redis.StringGetAsync(attemptsKey);
|
||||
if (attempts.HasValue && (int)attempts >= maxAttempts)
|
||||
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
|
||||
}
|
||||
|
||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||
await redis.StringSetAsync($"otp:admin:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
|
||||
_logger.LogWarning("DEV admin OTP for {Phone}: {Otp}", phone, otp);
|
||||
|
||||
try
|
||||
{
|
||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send admin OTP SMS");
|
||||
return (false, null, "SMS_FAILED", "Could not send verification code.");
|
||||
}
|
||||
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
|
||||
if (newAttempts == 1)
|
||||
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var code = OtpNormalizer.Normalize(request.Code);
|
||||
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var redis = _redis.GetDatabase();
|
||||
var storedOtp = await redis.StringGetAsync($"otp:admin:{phone}");
|
||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var admin = await _db.SystemAdmins
|
||||
.FirstOrDefaultAsync(a => a.Phone == phone && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||
if (admin is null)
|
||||
return (false, null, "NOT_FOUND", "No system admin account for this phone.");
|
||||
|
||||
await redis.KeyDeleteAsync($"otp:admin:{phone}");
|
||||
var tokens = await IssueTokensAsync(admin, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (payload is null || payload.Actor != MeeziActorKinds.SystemAdmin)
|
||||
return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired.");
|
||||
|
||||
var admin = await _db.SystemAdmins
|
||||
.FirstOrDefaultAsync(a => a.Id == payload.UserId && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||
if (admin is null)
|
||||
return (false, null, "NOT_FOUND", "Admin no longer exists.");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
var tokens = await IssueTokensAsync(admin, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.SystemAdmin admin,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = _jwtTokenService.CreateAdminAccessToken(admin);
|
||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
refreshToken,
|
||||
new RefreshTokenPayload(
|
||||
admin.Id,
|
||||
string.Empty,
|
||||
"SystemAdmin",
|
||||
PlanTier.Enterprise.ToString(),
|
||||
"fa",
|
||||
MeeziActorKinds.SystemAdmin),
|
||||
TimeSpan.FromDays(refreshDays),
|
||||
cancellationToken);
|
||||
|
||||
return new AuthTokenResponse(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
_jwtTokenService.GetAccessTokenExpiry(),
|
||||
admin.Id,
|
||||
string.Empty,
|
||||
"SystemAdmin",
|
||||
PlanTier.Enterprise.ToString(),
|
||||
"fa",
|
||||
MeeziActorKinds.SystemAdmin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IAdminJwtTokenService
|
||||
{
|
||||
string CreateAdminAccessToken(SystemAdmin admin);
|
||||
string CreateRefreshToken();
|
||||
DateTime GetAccessTokenExpiry();
|
||||
}
|
||||
|
||||
public class AdminJwtTokenService : IAdminJwtTokenService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AdminJwtTokenService(IConfiguration configuration) => _configuration = configuration;
|
||||
|
||||
public string CreateAdminAccessToken(SystemAdmin admin)
|
||||
{
|
||||
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
|
||||
var audience = _configuration["Jwt:Audience"] ?? "meezi-admin";
|
||||
var expiryDays = _configuration.GetValue("Jwt:AccessTokenExpiryDays", 7);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, admin.Id),
|
||||
new(ClaimTypes.Role, "SystemAdmin"),
|
||||
new(MeeziClaimTypes.Role, "SystemAdmin"),
|
||||
new(MeeziClaimTypes.Actor, MeeziActorKinds.SystemAdmin),
|
||||
new(MeeziClaimTypes.Language, "fa"),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer,
|
||||
audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddDays(expiryDays),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public string CreateRefreshToken() => Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
|
||||
public DateTime GetAccessTokenExpiry()
|
||||
{
|
||||
var expiryDays = _configuration.GetValue("Jwt:AccessTokenExpiryDays", 7);
|
||||
return DateTime.UtcNow.AddDays(expiryDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.Admin.API.Hubs;
|
||||
using Meezi.Admin.API.Models;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IAdminNotificationService
|
||||
{
|
||||
Task<IReadOnlyList<AdminNotificationRowDto>> ListAsync(
|
||||
int limit,
|
||||
string? cafeId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<BroadcastNotificationResult> BroadcastAsync(
|
||||
string title,
|
||||
string body,
|
||||
string adminId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<bool> DeleteAsync(string notificationId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class AdminNotificationService : IAdminNotificationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHubContext<KdsHub> _hub;
|
||||
|
||||
public AdminNotificationService(AppDbContext db, IHubContext<KdsHub> hub)
|
||||
{
|
||||
_db = db;
|
||||
_hub = hub;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminNotificationRowDto>> ListAsync(
|
||||
int limit,
|
||||
string? cafeId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 200);
|
||||
var q =
|
||||
from n in _db.CafeNotifications.AsNoTracking()
|
||||
join c in _db.Cafes.AsNoTracking() on n.CafeId equals c.Id
|
||||
select new { n, c };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cafeId))
|
||||
q = q.Where(x => x.n.CafeId == cafeId);
|
||||
|
||||
return await q
|
||||
.OrderByDescending(x => x.n.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(x => new AdminNotificationRowDto(
|
||||
x.n.Id,
|
||||
x.n.CafeId,
|
||||
x.c.Name,
|
||||
x.n.Type,
|
||||
x.n.Title,
|
||||
x.n.Body,
|
||||
x.n.IsRead,
|
||||
x.n.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<BroadcastNotificationResult> BroadcastAsync(
|
||||
string title,
|
||||
string body,
|
||||
string adminId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cafes = await _db.Cafes
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsSuspended)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var notifications = new List<Core.Entities.CafeNotification>();
|
||||
foreach (var cafeId in cafes)
|
||||
{
|
||||
notifications.Add(new Core.Entities.CafeNotification
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Type = "platform_broadcast",
|
||||
Title = title.Trim(),
|
||||
Body = body.Trim(),
|
||||
ReferenceId = adminId
|
||||
});
|
||||
}
|
||||
|
||||
_db.CafeNotifications.AddRange(notifications);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
foreach (var n in notifications)
|
||||
{
|
||||
var dto = new CafeNotificationDto(
|
||||
n.Id,
|
||||
n.Type,
|
||||
n.Title,
|
||||
n.Body,
|
||||
n.ReferenceId,
|
||||
n.TableNumber,
|
||||
n.IsRead,
|
||||
n.CreatedAt);
|
||||
|
||||
await _hub.Clients.Group(KdsHub.GroupName(n.CafeId))
|
||||
.SendAsync("NotificationReceived", dto, ct);
|
||||
}
|
||||
|
||||
return new BroadcastNotificationResult(cafes.Count, notifications.Count);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string notificationId, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _db.CafeNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(n => n.Id == notificationId, ct);
|
||||
|
||||
if (row is null || row.DeletedAt is not null)
|
||||
return false;
|
||||
|
||||
row.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.Admin.API.Models;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Platform;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Discover;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IAdminPlatformService
|
||||
{
|
||||
Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PlanDefinitionDto>> GetPlansAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> UpdatePlanAsync(PlanTier tier, UpdatePlanRequest request, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PlatformSettingDto>> GetSettingsAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> UpdateSettingAsync(string key, UpdateSettingRequest request, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PlatformFeatureDto>> GetFeaturesAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
|
||||
Task<AdminCafeDiscoverProfileDto?> GetCafeDiscoverProfileAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<AdminCafeDiscoverProfileDto?> UpsertCafeDiscoverProfileAsync(
|
||||
string cafeId,
|
||||
AdminUpsertCafeDiscoverProfileRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class AdminPlatformService : IAdminPlatformService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
private readonly IPlatformRuntimeConfig _runtime;
|
||||
|
||||
public AdminPlatformService(
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog,
|
||||
IPlatformRuntimeConfig runtime)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_runtime = runtime;
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = await _db.Cafes.CountAsync(cancellationToken);
|
||||
var suspended = await _db.Cafes.CountAsync(c => c.IsSuspended, cancellationToken);
|
||||
var openTickets = await _db.SupportTickets.CountAsync(
|
||||
t => t.Status != SupportTicketStatus.Closed && t.Status != SupportTicketStatus.Resolved,
|
||||
cancellationToken);
|
||||
var plans = await _db.PlatformPlanDefinitions.CountAsync(cancellationToken);
|
||||
|
||||
return new AdminDashboardStatsDto(
|
||||
total,
|
||||
total - suspended,
|
||||
suspended,
|
||||
openTickets,
|
||||
plans);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PlanDefinitionDto>> GetPlansAsync(CancellationToken cancellationToken = default) =>
|
||||
_catalog.GetPlansAsync(cancellationToken);
|
||||
|
||||
public async Task<bool> UpdatePlanAsync(PlanTier tier, UpdatePlanRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var plan = await _db.PlatformPlanDefinitions.FirstOrDefaultAsync(p => p.Tier == tier, cancellationToken);
|
||||
if (plan is null)
|
||||
{
|
||||
plan = new PlatformPlanDefinition { Tier = tier };
|
||||
_db.PlatformPlanDefinitions.Add(plan);
|
||||
}
|
||||
|
||||
plan.DisplayNameFa = request.DisplayNameFa.Trim();
|
||||
plan.DisplayNameEn = request.DisplayNameEn?.Trim();
|
||||
plan.MonthlyPriceToman = request.MonthlyPriceToman;
|
||||
plan.IsBillableOnline = request.IsBillableOnline;
|
||||
plan.IsActive = request.IsActive;
|
||||
plan.SortOrder = request.SortOrder;
|
||||
plan.LimitsJson = JsonSerializer.Serialize(request.Limits, JsonOpts);
|
||||
plan.FeaturesJson = request.FeatureKeys is null
|
||||
? null
|
||||
: JsonSerializer.Serialize(request.FeatureKeys, JsonOpts);
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
_catalog.InvalidateCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PlatformSettingDto>> GetSettingsAsync(CancellationToken cancellationToken = default) =>
|
||||
_catalog.GetSettingsAsync(cancellationToken);
|
||||
|
||||
public async Task<bool> UpdateSettingAsync(string key, UpdateSettingRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var setting = await _db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
if (setting is null) return false;
|
||||
|
||||
setting.Value = request.Value;
|
||||
if (request.DescriptionFa is not null)
|
||||
setting.DescriptionFa = request.DescriptionFa;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
_catalog.InvalidateCache();
|
||||
_runtime.InvalidateCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PlatformFeatureDto>> GetFeaturesAsync(CancellationToken cancellationToken = default) =>
|
||||
_catalog.GetFeaturesAsync(cancellationToken);
|
||||
|
||||
public async Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var feature = await _db.PlatformFeatures.FirstOrDefaultAsync(f => f.Key == featureKey, cancellationToken);
|
||||
if (feature is null) return false;
|
||||
|
||||
feature.DisplayNameFa = request.DisplayNameFa.Trim();
|
||||
feature.DisplayNameEn = request.DisplayNameEn?.Trim();
|
||||
feature.ModuleGroup = request.ModuleGroup.Trim();
|
||||
feature.IsEnabledGlobally = request.IsEnabledGlobally;
|
||||
feature.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
_catalog.InvalidateCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _db.Cafes
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Select(c => new AdminCafeListItemDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Slug,
|
||||
c.City ?? "",
|
||||
c.PlanTier,
|
||||
c.PlanExpiresAt,
|
||||
c.IsSuspended,
|
||||
c.IsVerified,
|
||||
c.Branches.Count,
|
||||
c.Employees.Count(e => e.DeletedAt == null),
|
||||
c.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return false;
|
||||
|
||||
if (request.PlanTier.HasValue)
|
||||
cafe.PlanTier = request.PlanTier.Value;
|
||||
if (request.PlanExpiresAt.HasValue)
|
||||
cafe.PlanExpiresAt = request.PlanExpiresAt;
|
||||
if (request.IsSuspended.HasValue)
|
||||
cafe.IsSuspended = request.IsSuspended.Value;
|
||||
if (request.IsVerified.HasValue)
|
||||
cafe.IsVerified = request.IsVerified.Value;
|
||||
if (request.DiscoverBadges is not null)
|
||||
cafe.DiscoverBadgesJson = DiscoverBadgesSerializer.Serialize(request.DiscoverBadges);
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SetCafeFeatureOverrideAsync(
|
||||
string cafeId,
|
||||
CafeFeatureOverrideRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = await _db.Cafes.AnyAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (!exists) return false;
|
||||
|
||||
var row = await _db.CafeFeatureOverrides
|
||||
.FirstOrDefaultAsync(o => o.CafeId == cafeId && o.FeatureKey == request.FeatureKey, cancellationToken);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new CafeFeatureOverride { CafeId = cafeId, FeatureKey = request.FeatureKey };
|
||||
_db.CafeFeatureOverrides.Add(row);
|
||||
}
|
||||
|
||||
row.IsEnabled = request.IsEnabled;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AdminCafeDiscoverProfileDto?> GetCafeDiscoverProfileAsync(
|
||||
string cafeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var profile = CafeDiscoverProfileSerializer.Deserialize(cafe.DiscoverProfileJson);
|
||||
return MapAdminDiscoverProfile(cafe.Id, cafe.Name, profile);
|
||||
}
|
||||
|
||||
public async Task<AdminCafeDiscoverProfileDto?> UpsertCafeDiscoverProfileAsync(
|
||||
string cafeId,
|
||||
AdminUpsertCafeDiscoverProfileRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var profile = CafeDiscoverProfileSerializer.Sanitize(new CafeDiscoverProfile
|
||||
{
|
||||
Themes = request.Themes?.ToList() ?? [],
|
||||
Size = request.Size,
|
||||
Floors = request.Floors,
|
||||
Vibes = request.Vibes?.ToList() ?? [],
|
||||
Occasions = request.Occasions?.ToList() ?? [],
|
||||
SpaceFeatures = request.SpaceFeatures?.ToList() ?? [],
|
||||
NoiseLevel = request.NoiseLevel,
|
||||
PriceTier = request.PriceTier
|
||||
});
|
||||
|
||||
cafe.DiscoverProfileJson = CafeDiscoverProfileSerializer.Serialize(profile);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return MapAdminDiscoverProfile(cafe.Id, cafe.Name, profile);
|
||||
}
|
||||
|
||||
private static AdminCafeDiscoverProfileDto MapAdminDiscoverProfile(
|
||||
string cafeId,
|
||||
string cafeName,
|
||||
CafeDiscoverProfile profile) =>
|
||||
new(
|
||||
cafeId,
|
||||
cafeName,
|
||||
profile.Themes,
|
||||
profile.Size,
|
||||
profile.Floors,
|
||||
profile.Vibes,
|
||||
profile.Occasions,
|
||||
profile.SpaceFeatures,
|
||||
profile.NoiseLevel,
|
||||
profile.PriceTier);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Meezi.Admin.API.Controllers;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
|
||||
{
|
||||
// ── Posts ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<object> ListPostsAsync(int page, int limit, bool? published, CancellationToken ct)
|
||||
{
|
||||
var q = db.WebsiteBlogPosts.AsQueryable();
|
||||
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
|
||||
var total = await q.CountAsync(ct);
|
||||
var posts = await q.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
|
||||
}
|
||||
|
||||
public async Task<object?> GetPostAsync(string id, CancellationToken ct)
|
||||
{
|
||||
var post = await db.WebsiteBlogPosts.FindAsync([id], ct);
|
||||
return post is null ? null : MapPost(post);
|
||||
}
|
||||
|
||||
public async Task<object> CreatePostAsync(UpsertPostRequest req, CancellationToken ct)
|
||||
{
|
||||
var post = new WebsiteBlogPost
|
||||
{
|
||||
Slug = req.Slug.Trim().ToLowerInvariant(),
|
||||
TitleFa = req.TitleFa,
|
||||
TitleEn = req.TitleEn ?? "",
|
||||
ExcerptFa = req.ExcerptFa ?? "",
|
||||
ExcerptEn = req.ExcerptEn ?? "",
|
||||
ContentFa = req.ContentFa,
|
||||
ContentEn = req.ContentEn ?? "",
|
||||
CategoryFa = req.CategoryFa ?? "",
|
||||
CategoryEn = req.CategoryEn ?? "",
|
||||
Author = req.Author ?? "تیم میزی",
|
||||
TagsJson = req.TagsJson ?? "[]",
|
||||
CoverImage = req.CoverImage,
|
||||
IsPublished = req.IsPublished,
|
||||
PublishedAt = req.IsPublished ? DateTime.UtcNow : null,
|
||||
};
|
||||
db.WebsiteBlogPosts.Add(post);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return MapPost(post);
|
||||
}
|
||||
|
||||
public async Task<object?> UpdatePostAsync(string id, UpsertPostRequest req, CancellationToken ct)
|
||||
{
|
||||
var post = await db.WebsiteBlogPosts.FindAsync([id], ct);
|
||||
if (post is null) return null;
|
||||
|
||||
post.Slug = req.Slug.Trim().ToLowerInvariant();
|
||||
post.TitleFa = req.TitleFa;
|
||||
post.TitleEn = req.TitleEn ?? "";
|
||||
post.ExcerptFa = req.ExcerptFa ?? "";
|
||||
post.ExcerptEn = req.ExcerptEn ?? "";
|
||||
post.ContentFa = req.ContentFa;
|
||||
post.ContentEn = req.ContentEn ?? "";
|
||||
post.CategoryFa = req.CategoryFa ?? "";
|
||||
post.CategoryEn = req.CategoryEn ?? "";
|
||||
post.Author = req.Author ?? post.Author;
|
||||
post.TagsJson = req.TagsJson ?? "[]";
|
||||
post.CoverImage = req.CoverImage;
|
||||
if (req.IsPublished && !post.IsPublished) post.PublishedAt = DateTime.UtcNow;
|
||||
post.IsPublished = req.IsPublished;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return MapPost(post);
|
||||
}
|
||||
|
||||
public async Task DeletePostAsync(string id, CancellationToken ct)
|
||||
{
|
||||
var post = await db.WebsiteBlogPosts.FindAsync([id], ct);
|
||||
if (post is not null) { post.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); }
|
||||
}
|
||||
|
||||
public async Task SetPublishedAsync(string id, bool published, CancellationToken ct)
|
||||
{
|
||||
var post = await db.WebsiteBlogPosts.FindAsync([id], ct);
|
||||
if (post is null) return;
|
||||
post.IsPublished = published;
|
||||
if (published && post.PublishedAt is null) post.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<object> ListCommentsAsync(bool? approved, int page, int limit, CancellationToken ct)
|
||||
{
|
||||
var q = db.WebsiteComments.AsQueryable();
|
||||
if (approved.HasValue) q = q.Where(c => c.IsApproved == approved.Value);
|
||||
var total = await q.CountAsync(ct);
|
||||
var comments = await q.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
return new
|
||||
{
|
||||
Comments = comments.Select(c => new
|
||||
{
|
||||
c.Id, c.PostSlug, c.AuthorName, c.AuthorEmail,
|
||||
c.Content, c.IsApproved, c.CreatedAt, c.IpAddress,
|
||||
}),
|
||||
Total = total, Page = page, Limit = limit,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetCommentApprovedAsync(string id, bool approved, CancellationToken ct)
|
||||
{
|
||||
var c = await db.WebsiteComments.FindAsync([id], ct);
|
||||
if (c is null) return;
|
||||
c.IsApproved = approved;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteCommentAsync(string id, CancellationToken ct)
|
||||
{
|
||||
var c = await db.WebsiteComments.FindAsync([id], ct);
|
||||
if (c is not null) { c.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); }
|
||||
}
|
||||
|
||||
// ── Demo requests ─────────────────────────────────────────────────────
|
||||
|
||||
public async Task<object> ListDemoRequestsAsync(string? status, int page, int limit, CancellationToken ct)
|
||||
{
|
||||
var q = db.DemoRequests.AsQueryable();
|
||||
if (status is not null && Enum.TryParse<DemoRequestStatus>(status, true, out var s))
|
||||
q = q.Where(r => r.Status == s);
|
||||
var total = await q.CountAsync(ct);
|
||||
var reqs = await q.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
return new
|
||||
{
|
||||
Requests = reqs.Select(r => new
|
||||
{
|
||||
r.Id, r.ContactName, r.BusinessName, r.Phone, r.Email,
|
||||
r.BranchCount, r.Notes, r.Source, r.AdminNotes,
|
||||
Status = r.Status.ToString(), r.ContactedAt, r.CreatedAt,
|
||||
}),
|
||||
Total = total, Page = page, Limit = limit,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateDemoStatusAsync(string id, string status, string? adminNotes, CancellationToken ct)
|
||||
{
|
||||
var req = await db.DemoRequests.FindAsync([id], ct);
|
||||
if (req is null) return;
|
||||
if (Enum.TryParse<DemoRequestStatus>(status, true, out var s)) req.Status = s;
|
||||
if (adminNotes is not null) req.AdminNotes = adminNotes;
|
||||
if (s == DemoRequestStatus.Contacted && req.ContactedAt is null) req.ContactedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ── Mapper ────────────────────────────────────────────────────────────
|
||||
|
||||
private static object MapPost(WebsiteBlogPost p) => new
|
||||
{
|
||||
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
|
||||
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
|
||||
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Meezi.Admin.API.Controllers;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IAdminWebsiteService
|
||||
{
|
||||
Task<object> ListPostsAsync(int page, int limit, bool? published, CancellationToken ct);
|
||||
Task<object?> GetPostAsync(string id, CancellationToken ct);
|
||||
Task<object> CreatePostAsync(UpsertPostRequest req, CancellationToken ct);
|
||||
Task<object?> UpdatePostAsync(string id, UpsertPostRequest req, CancellationToken ct);
|
||||
Task DeletePostAsync(string id, CancellationToken ct);
|
||||
Task SetPublishedAsync(string id, bool published, CancellationToken ct);
|
||||
|
||||
Task<object> ListCommentsAsync(bool? approved, int page, int limit, CancellationToken ct);
|
||||
Task SetCommentApprovedAsync(string id, bool approved, CancellationToken ct);
|
||||
Task DeleteCommentAsync(string id, CancellationToken ct);
|
||||
|
||||
Task<object> ListDemoRequestsAsync(string? status, int page, int limit, CancellationToken ct);
|
||||
Task UpdateDemoStatusAsync(string id, string status, string? adminNotes, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using Meezi.Admin.API.Models;
|
||||
using Meezi.Core.Platform;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public interface IPlatformIntegrationService
|
||||
{
|
||||
Task<PlatformIntegrationsDto> GetIntegrationsAsync(CancellationToken ct = default);
|
||||
Task SaveIntegrationsAsync(UpdatePlatformIntegrationsRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class PlatformIntegrationService : IPlatformIntegrationService
|
||||
{
|
||||
public const string KeyActiveGateway = "payment.activeGateway";
|
||||
public const string KeyKavenegarApi = "integrations.kavenegar.apiKey";
|
||||
public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate";
|
||||
public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled";
|
||||
|
||||
private static readonly (string Id, string NameFa, string Prefix)[] Gateways =
|
||||
[
|
||||
("zarinpal", "زرینپال", "payment.zarinpal"),
|
||||
("tara", "تارا", "payment.tara"),
|
||||
("snapppay", "اسنپپی", "payment.snapppay"),
|
||||
("nextpay", "نکستپی", "payment.nextpay"),
|
||||
("vandar", "وندار", "payment.vandar")
|
||||
];
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
private readonly IPlatformRuntimeConfig _runtime;
|
||||
|
||||
public PlatformIntegrationService(
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog,
|
||||
IPlatformRuntimeConfig runtime)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_runtime = runtime;
|
||||
}
|
||||
|
||||
public async Task<PlatformIntegrationsDto> GetIntegrationsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var settings = await _db.PlatformSettings.AsNoTracking().ToListAsync(ct);
|
||||
var map = settings.ToDictionary(s => s.Key, s => s.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var active = map.GetValueOrDefault(KeyActiveGateway) ?? "zarinpal";
|
||||
var gateways = Gateways.Select(g => MapGateway(g.Id, g.NameFa, g.Prefix, active, map)).ToList();
|
||||
|
||||
var kavenegar = new KavenegarConfigDto(
|
||||
map.GetValueOrDefault(KeyKavenegarEnabled) is "true",
|
||||
MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)),
|
||||
map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "verify",
|
||||
HasSecret(map, KeyKavenegarApi));
|
||||
|
||||
var ai = new AiIntegrationsConfigDto(
|
||||
new OpenAiIntegrationConfigDto(
|
||||
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiEnabled) is not "false",
|
||||
MaskSecret(map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiApiKey)),
|
||||
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiModel) ?? "gpt-4o-mini",
|
||||
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled) is not "false",
|
||||
HasSecret(map, PlatformIntegrationKeys.OpenAiApiKey)),
|
||||
new MeshyIntegrationConfigDto(
|
||||
map.GetValueOrDefault(PlatformIntegrationKeys.MeshyEnabled) is not "false",
|
||||
MaskSecret(map.GetValueOrDefault(PlatformIntegrationKeys.MeshyApiKey)),
|
||||
map.GetValueOrDefault(PlatformIntegrationKeys.MeshyMenu3dEnabled) is not "false",
|
||||
HasSecret(map, PlatformIntegrationKeys.MeshyApiKey)));
|
||||
|
||||
return new PlatformIntegrationsDto(active, gateways, kavenegar, ai);
|
||||
}
|
||||
|
||||
public async Task SaveIntegrationsAsync(UpdatePlatformIntegrationsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var active = request.ActivePaymentGateway.Trim().ToLowerInvariant();
|
||||
if (!Gateways.Any(g => g.Id == active))
|
||||
active = "zarinpal";
|
||||
|
||||
await UpsertAsync(KeyActiveGateway, active, "payment", "درگاه پیشفرض اشتراک", ct);
|
||||
|
||||
foreach (var gw in request.PaymentGateways)
|
||||
{
|
||||
var meta = Gateways.FirstOrDefault(g => g.Id == gw.Id);
|
||||
if (string.IsNullOrEmpty(meta.Id)) continue;
|
||||
|
||||
await UpsertAsync($"{meta.Prefix}.enabled", gw.IsEnabled ? "true" : "false", "payment", $"فعال {meta.NameFa}", ct);
|
||||
await UpsertAsync($"{meta.Prefix}.sandbox", gw.Sandbox ? "true" : "false", "payment", $"حالت تست {meta.NameFa}", ct);
|
||||
|
||||
if (gw.Id == "zarinpal")
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(gw.MerchantId))
|
||||
await UpsertAsync($"{meta.Prefix}.merchantId", gw.MerchantId.Trim(), "payment", "مرچنت زرینپال", ct);
|
||||
}
|
||||
else if (gw.Id is "nextpay" or "vandar")
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(gw.ApiKey) && !IsMaskedPlaceholder(gw.ApiKey))
|
||||
await UpsertAsync($"{meta.Prefix}.apiKey", gw.ApiKey.Trim(), "payment", $"توکن {meta.NameFa}", ct);
|
||||
}
|
||||
|
||||
if (gw.Credentials is not null)
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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 _db.SaveChangesAsync(ct);
|
||||
_catalog.InvalidateCache();
|
||||
_runtime.InvalidateCache();
|
||||
}
|
||||
|
||||
private async Task SaveCredentialsAsync(
|
||||
string prefix,
|
||||
string gatewayId,
|
||||
UpdatePaymentGatewayCredentialsRequest creds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(creds.BaseUrl))
|
||||
await UpsertAsync($"{prefix}.baseUrl", creds.BaseUrl.Trim(), "payment", "آدرس API", ct);
|
||||
|
||||
if (gatewayId == "tara")
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(creds.Username))
|
||||
await UpsertAsync($"{prefix}.username", creds.Username.Trim(), "payment", "نام کاربری تارا", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.Password) && !IsMaskedPlaceholder(creds.Password))
|
||||
await UpsertAsync($"{prefix}.password", creds.Password.Trim(), "payment", "رمز تارا", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.BranchCode))
|
||||
await UpsertAsync($"{prefix}.branchCode", creds.BranchCode.Trim(), "payment", "کد شعبه تارا", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.TerminalCode))
|
||||
await UpsertAsync($"{prefix}.terminalCode", creds.TerminalCode.Trim(), "payment", "ترمینال تارا", ct);
|
||||
}
|
||||
else if (gatewayId == "snapppay")
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(creds.ClientId))
|
||||
await UpsertAsync($"{prefix}.clientId", creds.ClientId.Trim(), "payment", "Client ID اسنپپی", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.ClientSecret) && !IsMaskedPlaceholder(creds.ClientSecret))
|
||||
await UpsertAsync($"{prefix}.clientSecret", creds.ClientSecret.Trim(), "payment", "Client Secret اسنپپی", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.Username))
|
||||
await UpsertAsync($"{prefix}.username", creds.Username.Trim(), "payment", "نام کاربری اسنپپی", ct);
|
||||
if (!string.IsNullOrWhiteSpace(creds.Password) && !IsMaskedPlaceholder(creds.Password))
|
||||
await UpsertAsync($"{prefix}.password", creds.Password.Trim(), "payment", "رمز اسنپپی", ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(string key, string value, string category, string descFa, CancellationToken ct)
|
||||
{
|
||||
var row = await _db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (row is null)
|
||||
{
|
||||
_db.PlatformSettings.Add(new PlatformSetting
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Category = category,
|
||||
DescriptionFa = descFa
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static PaymentGatewayConfigDto MapGateway(
|
||||
string id,
|
||||
string nameFa,
|
||||
string prefix,
|
||||
string activeGateway,
|
||||
Dictionary<string, string> map)
|
||||
{
|
||||
var enabled = map.GetValueOrDefault($"{prefix}.enabled") is "true";
|
||||
var sandbox = map.GetValueOrDefault($"{prefix}.sandbox") is not "false";
|
||||
string? merchantId = null;
|
||||
string? apiKey = null;
|
||||
var hasSecret = false;
|
||||
GatewayCredentialsDto? credentials = null;
|
||||
|
||||
if (id == "zarinpal")
|
||||
{
|
||||
merchantId = map.GetValueOrDefault($"{prefix}.merchantId");
|
||||
hasSecret = HasSecret(map, $"{prefix}.merchantId");
|
||||
}
|
||||
else if (id is "nextpay" or "vandar")
|
||||
{
|
||||
apiKey = MaskSecret(map.GetValueOrDefault($"{prefix}.apiKey"));
|
||||
hasSecret = HasSecret(map, $"{prefix}.apiKey");
|
||||
}
|
||||
else if (id == "tara")
|
||||
{
|
||||
credentials = new GatewayCredentialsDto(
|
||||
map.GetValueOrDefault($"{prefix}.username"),
|
||||
MaskSecret(map.GetValueOrDefault($"{prefix}.password")),
|
||||
map.GetValueOrDefault($"{prefix}.branchCode"),
|
||||
map.GetValueOrDefault($"{prefix}.terminalCode"),
|
||||
null,
|
||||
null,
|
||||
map.GetValueOrDefault($"{prefix}.baseUrl"),
|
||||
HasSecret(map, $"{prefix}.password"),
|
||||
false);
|
||||
hasSecret = credentials.HasStoredPassword;
|
||||
}
|
||||
else if (id == "snapppay")
|
||||
{
|
||||
credentials = new GatewayCredentialsDto(
|
||||
map.GetValueOrDefault($"{prefix}.username"),
|
||||
MaskSecret(map.GetValueOrDefault($"{prefix}.password")),
|
||||
null,
|
||||
null,
|
||||
map.GetValueOrDefault($"{prefix}.clientId"),
|
||||
MaskSecret(map.GetValueOrDefault($"{prefix}.clientSecret")),
|
||||
map.GetValueOrDefault($"{prefix}.baseUrl"),
|
||||
HasSecret(map, $"{prefix}.password"),
|
||||
HasSecret(map, $"{prefix}.clientSecret"));
|
||||
hasSecret = credentials.HasStoredPassword || credentials.HasStoredClientSecret;
|
||||
}
|
||||
|
||||
return new PaymentGatewayConfigDto(
|
||||
id,
|
||||
nameFa,
|
||||
enabled,
|
||||
activeGateway == id,
|
||||
merchantId,
|
||||
apiKey,
|
||||
sandbox,
|
||||
hasSecret,
|
||||
credentials);
|
||||
}
|
||||
|
||||
private static bool HasSecret(Dictionary<string, string> map, string key) =>
|
||||
!string.IsNullOrWhiteSpace(map.GetValueOrDefault(key));
|
||||
|
||||
private static string? MaskSecret(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : "••••••••";
|
||||
|
||||
private static bool IsMaskedPlaceholder(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) || value.Contains("••••", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.Admin.API.Services;
|
||||
|
||||
public record RefreshTokenPayload(
|
||||
string UserId,
|
||||
string CafeId,
|
||||
string Role,
|
||||
string PlanTier,
|
||||
string Language,
|
||||
string Actor = Meezi.Core.Constants.MeeziActorKinds.SystemAdmin);
|
||||
|
||||
public interface IRefreshTokenStore
|
||||
{
|
||||
Task StoreAsync(string refreshToken, RefreshTokenPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
Task<RefreshTokenPayload?> GetAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class RedisRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public RedisRefreshTokenStore(IConnectionMultiplexer redis) => _redis = redis;
|
||||
|
||||
private static string Key(string token) => $"admin:refresh:{token}";
|
||||
|
||||
public async Task StoreAsync(string refreshToken, RefreshTokenPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.StringSetAsync(Key(refreshToken), JsonSerializer.Serialize(payload), ttl);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenPayload?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var value = await db.StringGetAsync(Key(refreshToken));
|
||||
if (value.IsNullOrEmpty) return null;
|
||||
return JsonSerializer.Deserialize<RefreshTokenPayload>(value.ToString());
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.KeyDeleteAsync(Key(refreshToken));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user