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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -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));
}
}