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,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Authorize(Roles = "SystemAdmin")]
[ApiController]
public abstract class AdminApiControllerBase : ControllerBase
{
protected string RequireAdminId(ITenantContext tenant)
{
if (!tenant.IsSystemAdmin || string.IsNullOrEmpty(tenant.UserId))
throw new InvalidOperationException("System admin context required.");
return tenant.UserId;
}
}
@@ -0,0 +1,85 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[ApiController]
[Route("api/admin/auth")]
public class AdminAuthController : ControllerBase
{
private readonly IAdminAuthService _auth;
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
public AdminAuthController(
IAdminAuthService auth,
IValidator<SendOtpRequest> sendOtpValidator,
IValidator<VerifyOtpRequest> verifyOtpValidator,
IValidator<RefreshTokenRequest> refreshValidator)
{
_auth = auth;
_sendOtpValidator = sendOtpValidator;
_verifyOtpValidator = verifyOtpValidator;
_refreshValidator = refreshValidator;
}
[HttpPost("send-otp")]
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken cancellationToken)
{
var validation = await _sendOtpValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _auth.SendOtpAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<SendOtpResponse>(true, data));
}
[HttpPost("verify-otp")]
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken cancellationToken)
{
var validation = await _verifyOtpValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _auth.VerifyOtpAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var validation = await _refreshValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _auth.RefreshAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
private static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{
var first = validation.Errors.First();
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private IActionResult ErrorResult(string code, string message) =>
code switch
{
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"RATE_LIMITED" => StatusCode(429, new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
};
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/cafes")]
public class AdminCafesController : AdminApiControllerBase
{
private readonly IAdminPlatformService _platform;
public AdminCafesController(IAdminPlatformService platform)
{
_platform = platform;
}
[HttpGet]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
var cafes = await _platform.ListCafesAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, cafes));
}
[HttpPatch("{cafeId}")]
public async Task<IActionResult> Patch(string cafeId, [FromBody] AdminCafePatchRequest request, CancellationToken cancellationToken)
{
var ok = await _platform.PatchCafeAsync(cafeId, request, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
[HttpPut("{cafeId}/features")]
public async Task<IActionResult> SetFeature(
string cafeId,
[FromBody] CafeFeatureOverrideRequest request,
CancellationToken cancellationToken)
{
var ok = await _platform.SetCafeFeatureOverrideAsync(cafeId, request, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<object>(true, new { cafeId, request.FeatureKey, request.IsEnabled }));
}
[HttpGet("{cafeId}/discover-profile")]
public async Task<IActionResult> GetDiscoverProfile(string cafeId, CancellationToken cancellationToken)
{
var data = await _platform.GetCafeDiscoverProfileAsync(cafeId, cancellationToken);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<AdminCafeDiscoverProfileDto>(true, data));
}
[HttpPut("{cafeId}/discover-profile")]
public async Task<IActionResult> PutDiscoverProfile(
string cafeId,
[FromBody] AdminUpsertCafeDiscoverProfileRequest request,
CancellationToken cancellationToken)
{
var data = await _platform.UpsertCafeDiscoverProfileAsync(cafeId, request, cancellationToken);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<AdminCafeDiscoverProfileDto>(true, data));
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/dashboard")]
public class AdminDashboardController : AdminApiControllerBase
{
private readonly IAdminPlatformService _platform;
public AdminDashboardController(IAdminPlatformService platform)
{
_platform = platform;
}
[HttpGet("stats")]
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
{
var stats = await _platform.GetDashboardStatsAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, stats));
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/features")]
public class AdminFeaturesController : AdminApiControllerBase
{
private readonly IAdminPlatformService _platform;
public AdminFeaturesController(IAdminPlatformService platform)
{
_platform = platform;
}
[HttpGet]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
var features = await _platform.GetFeaturesAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, features));
}
[HttpPatch("{featureKey}")]
public async Task<IActionResult> Update(string featureKey, [FromBody] UpdateFeatureRequest request, CancellationToken cancellationToken)
{
var ok = await _platform.UpdateFeatureAsync(featureKey, request, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Feature not found.")));
return Ok(new ApiResponse<object>(true, new { featureKey }));
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/integrations")]
public class AdminIntegrationsController : AdminApiControllerBase
{
private readonly IPlatformIntegrationService _integrations;
public AdminIntegrationsController(IPlatformIntegrationService integrations)
{
_integrations = integrations;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var data = await _integrations.GetIntegrationsAsync(cancellationToken);
return Ok(new ApiResponse<PlatformIntegrationsDto>(true, data));
}
[HttpPut]
public async Task<IActionResult> Save(
[FromBody] UpdatePlatformIntegrationsRequest request,
CancellationToken cancellationToken)
{
await _integrations.SaveIntegrationsAsync(request, cancellationToken);
var data = await _integrations.GetIntegrationsAsync(cancellationToken);
return Ok(new ApiResponse<PlatformIntegrationsDto>(true, data));
}
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/notifications")]
public class AdminNotificationsController : AdminApiControllerBase
{
private readonly IAdminNotificationService _notifications;
public AdminNotificationsController(IAdminNotificationService notifications)
{
_notifications = notifications;
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] int limit = 50,
[FromQuery] string? cafeId = null,
CancellationToken cancellationToken = default)
{
var items = await _notifications.ListAsync(limit, cafeId, cancellationToken);
return Ok(new ApiResponse<object>(true, new { items, total = items.Count }));
}
[HttpPost("broadcast")]
public async Task<IActionResult> Broadcast(
[FromBody] BroadcastNotificationRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION", "Title is required.")));
var adminId = RequireAdminId(tenant);
var result = await _notifications.BroadcastAsync(
request.Title,
request.Body ?? string.Empty,
adminId,
cancellationToken);
return Ok(new ApiResponse<BroadcastNotificationResult>(true, result));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id, CancellationToken cancellationToken)
{
var ok = await _notifications.DeleteAsync(id, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Notification not found.")));
return Ok(new ApiResponse<object>(true, new { id }));
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Core.Enums;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/plans")]
public class AdminPlansController : AdminApiControllerBase
{
private readonly IAdminPlatformService _platform;
public AdminPlansController(IAdminPlatformService platform)
{
_platform = platform;
}
[HttpGet]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
var plans = await _platform.GetPlansAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, plans));
}
[HttpPut("{tier}")]
public async Task<IActionResult> Update(PlanTier tier, [FromBody] UpdatePlanRequest request, CancellationToken cancellationToken)
{
var ok = await _platform.UpdatePlanAsync(tier, request, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Plan not found.")));
var plans = await _platform.GetPlansAsync(cancellationToken);
var updated = plans.FirstOrDefault(p => p.Tier == tier);
return Ok(new ApiResponse<object>(true, updated));
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/settings")]
public class AdminSettingsController : AdminApiControllerBase
{
private readonly IAdminPlatformService _platform;
public AdminSettingsController(IAdminPlatformService platform)
{
_platform = platform;
}
[HttpGet]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
var settings = await _platform.GetSettingsAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, settings));
}
[HttpPatch("{key}")]
public async Task<IActionResult> Update(string key, [FromBody] UpdateSettingRequest request, CancellationToken cancellationToken)
{
var ok = await _platform.UpdateSettingAsync(key, request, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Setting not found.")));
return Ok(new ApiResponse<object>(true, new { key, request.Value }));
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Infrastructure.Models;
using Meezi.Infrastructure.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/tickets")]
public class AdminTicketsController : AdminApiControllerBase
{
private readonly ISupportTicketService _tickets;
public AdminTicketsController(ISupportTicketService tickets)
{
_tickets = tickets;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] SupportTicketStatus? status, CancellationToken cancellationToken)
{
var list = await _tickets.ListAllAsync(status, cancellationToken);
return Ok(new ApiResponse<object>(true, list));
}
[HttpGet("{ticketId}")]
public async Task<IActionResult> Get(string ticketId, CancellationToken cancellationToken)
{
var detail = await _tickets.GetAdminAsync(ticketId, cancellationToken);
if (detail is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Ticket not found.")));
return Ok(new ApiResponse<object>(true, detail));
}
[HttpPost("{ticketId}/messages")]
public async Task<IActionResult> Reply(
string ticketId,
[FromBody] ReplySupportTicketRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
var existing = await _tickets.GetAdminAsync(ticketId, cancellationToken);
if (existing is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Ticket not found.")));
if (existing.Ticket.Status is SupportTicketStatus.Closed)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("TICKET_CLOSED", "This ticket is closed.")));
var adminId = RequireAdminId(tenant);
var detail = await _tickets.ReplyAsAdminAsync(ticketId, adminId, request, cancellationToken);
if (detail is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Ticket not found.")));
return Ok(new ApiResponse<object>(true, detail));
}
[HttpPatch("{ticketId}")]
public async Task<IActionResult> Update(
string ticketId,
[FromBody] UpdateSupportTicketRequest request,
CancellationToken cancellationToken)
{
var detail = await _tickets.UpdateAdminAsync(ticketId, request, cancellationToken);
if (detail is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Ticket not found.")));
return Ok(new ApiResponse<object>(true, detail));
}
}
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Interfaces;
using Meezi.Admin.API.Services;
using Meezi.Shared;
namespace Meezi.Admin.API.Controllers;
[Route("api/admin/website")]
public class AdminWebsiteController(IAdminWebsiteService websiteAdmin) : AdminApiControllerBase
{
// ── Blog posts ────────────────────────────────────────────────────────
[HttpGet("posts")]
public async Task<IActionResult> ListPosts(
[FromQuery] int page = 1,
[FromQuery] int limit = 20,
[FromQuery] bool? published = null,
CancellationToken ct = default)
{
var result = await websiteAdmin.ListPostsAsync(page, limit, published, ct);
return Ok(new ApiResponse<object>(true, result));
}
[HttpGet("posts/{id}")]
public async Task<IActionResult> GetPost(string id, CancellationToken ct = default)
{
var post = await websiteAdmin.GetPostAsync(id, ct);
if (post is null) return NotFound(new ApiResponse<object>(false, null,
new ApiError("NOT_FOUND", "Post not found.")));
return Ok(new ApiResponse<object>(true, post));
}
[HttpPost("posts")]
public async Task<IActionResult> CreatePost([FromBody] UpsertPostRequest req, CancellationToken ct = default)
{
var post = await websiteAdmin.CreatePostAsync(req, ct);
return Ok(new ApiResponse<object>(true, post));
}
[HttpPut("posts/{id}")]
public async Task<IActionResult> UpdatePost(string id,
[FromBody] UpsertPostRequest req, CancellationToken ct = default)
{
var post = await websiteAdmin.UpdatePostAsync(id, req, ct);
if (post is null) return NotFound(new ApiResponse<object>(false, null,
new ApiError("NOT_FOUND", "Post not found.")));
return Ok(new ApiResponse<object>(true, post));
}
[HttpDelete("posts/{id}")]
public async Task<IActionResult> DeletePost(string id, CancellationToken ct = default)
{
await websiteAdmin.DeletePostAsync(id, ct);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPatch("posts/{id}/publish")]
public async Task<IActionResult> PublishPost(string id, CancellationToken ct = default)
{
await websiteAdmin.SetPublishedAsync(id, true, ct);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPatch("posts/{id}/unpublish")]
public async Task<IActionResult> UnpublishPost(string id, CancellationToken ct = default)
{
await websiteAdmin.SetPublishedAsync(id, false, ct);
return Ok(new ApiResponse<object>(true, null));
}
// ── Comments ──────────────────────────────────────────────────────────
[HttpGet("comments")]
public async Task<IActionResult> ListComments(
[FromQuery] bool? approved = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 30,
CancellationToken ct = default)
{
var result = await websiteAdmin.ListCommentsAsync(approved, page, limit, ct);
return Ok(new ApiResponse<object>(true, result));
}
[HttpPatch("comments/{id}/approve")]
public async Task<IActionResult> ApproveComment(string id, CancellationToken ct = default)
{
await websiteAdmin.SetCommentApprovedAsync(id, true, ct);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPatch("comments/{id}/reject")]
public async Task<IActionResult> RejectComment(string id, CancellationToken ct = default)
{
await websiteAdmin.SetCommentApprovedAsync(id, false, ct);
return Ok(new ApiResponse<object>(true, null));
}
[HttpDelete("comments/{id}")]
public async Task<IActionResult> DeleteComment(string id, CancellationToken ct = default)
{
await websiteAdmin.DeleteCommentAsync(id, ct);
return Ok(new ApiResponse<object>(true, null));
}
// ── Demo requests ─────────────────────────────────────────────────────
[HttpGet("demo-requests")]
public async Task<IActionResult> ListDemoRequests(
[FromQuery] string? status = null,
[FromQuery] int page = 1,
[FromQuery] int limit = 30,
CancellationToken ct = default)
{
var result = await websiteAdmin.ListDemoRequestsAsync(status, page, limit, ct);
return Ok(new ApiResponse<object>(true, result));
}
[HttpPatch("demo-requests/{id}/status")]
public async Task<IActionResult> UpdateDemoStatus(
string id,
[FromBody] UpdateDemoStatusRequest req,
CancellationToken ct = default)
{
await websiteAdmin.UpdateDemoStatusAsync(id, req.Status, req.AdminNotes, ct);
return Ok(new ApiResponse<object>(true, null));
}
}
public record UpsertPostRequest(
string Slug,
string TitleFa, string? TitleEn,
string? ExcerptFa, string? ExcerptEn,
string ContentFa, string? ContentEn,
string? CategoryFa, string? CategoryEn,
string? Author,
string? TagsJson,
string? CoverImage,
bool IsPublished);
public record UpdateDemoStatusRequest(string Status, string? AdminNotes);
@@ -0,0 +1,104 @@
using System.Text;
using System.Text.Json.Serialization;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Meezi.Admin.API.Hubs;
using Meezi.Admin.API.Services;
using Meezi.Admin.API.Validators;
using Meezi.Infrastructure;
using Serilog;
using StackExchange.Redis;
namespace Meezi.Admin.API.Extensions;
public static class AdminServiceCollectionExtensions
{
public static IServiceCollection AddMeeziAdminServices(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddInfrastructure(configuration);
services.AddScoped<IAdminAuthService, AdminAuthService>();
services.AddScoped<IAdminJwtTokenService, AdminJwtTokenService>();
services.AddSingleton<IRefreshTokenStore, RedisRefreshTokenStore>();
services.AddScoped<IAdminPlatformService, AdminPlatformService>();
services.AddScoped<IPlatformIntegrationService, PlatformIntegrationService>();
services.AddScoped<IAdminNotificationService, AdminNotificationService>();
services.AddScoped<IAdminWebsiteService, AdminWebsiteService>();
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.AddSignalR();
services.AddValidatorsFromAssemblyContaining<SendOtpRequestValidator>();
var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!";
var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi";
var jwtAudience = configuration["Jwt:Audience"] ?? "meezi-admin";
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
});
services.AddAuthorization();
var redisConnection = configuration.GetConnectionString("Redis") ?? "localhost:6379";
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect($"{redisConnection},abortConnect=false"));
services.AddCors(options =>
{
options.AddPolicy("AdminCors", policy =>
{
var origins = configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:3102"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
public static WebApplication ConfigureMeeziAdminPipeline(this WebApplication app)
{
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("AdminCors");
app.UseAuthentication();
app.UseMiddleware<Middleware.AdminTenantMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds");
app.MapGet("/health", () => Results.Ok(new { status = "healthy", service = "meezi-admin-api" }));
return app;
}
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Meezi.Admin.API.Hubs;
/// <summary>Shared hub name with merchant API so café dashboards receive platform broadcasts.</summary>
[Authorize(Roles = "SystemAdmin")]
public class KdsHub : Hub
{
public static string GroupName(string cafeId) => $"cafe:{cafeId}";
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Meezi.Admin.API</RootNamespace>
<AssemblyName>Meezi.Admin.API</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Meezi.Core\Meezi.Core.csproj" />
<ProjectReference Include="..\Meezi.Infrastructure\Meezi.Infrastructure.csproj" />
<ProjectReference Include="..\Meezi.Shared\Meezi.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
using System.Text.Json;
using Meezi.Core.Constants;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.Admin.API.Middleware;
public class AdminTenantMiddleware
{
private static readonly string[] PublicPrefixes =
[
"/api/admin/auth",
"/health",
"/swagger"
];
private readonly RequestDelegate _next;
public AdminTenantMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ITenantContext tenant)
{
if (IsPublicPath(context.Request.Path))
{
await _next(context);
return;
}
if (context.User.Identity?.IsAuthenticated != true)
{
await WriteUnauthorizedAsync(context, "UNAUTHORIZED", "Authentication required.");
return;
}
var actor = context.User.FindFirst(MeeziClaimTypes.Actor)?.Value;
if (actor != MeeziActorKinds.SystemAdmin)
{
await WriteUnauthorizedAsync(context, "FORBIDDEN", "System admin access required.");
return;
}
if (tenant is Infrastructure.Data.TenantContext scoped)
{
scoped.UserId = context.User.FindFirst("sub")?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
scoped.Language = context.User.FindFirst(MeeziClaimTypes.Language)?.Value ?? "fa";
scoped.IsSystemAdmin = true;
}
await _next(context);
}
private static bool IsPublicPath(PathString path) =>
PublicPrefixes.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
private static async Task WriteUnauthorizedAsync(HttpContext context, string code, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonSerializer.Serialize(new ApiResponse<object>(false, null, new ApiError(code, message))));
}
}
+51
View File
@@ -0,0 +1,51 @@
using Meezi.Core.Enums;
using Meezi.Core.Platform;
namespace Meezi.Admin.API.Models;
public record AdminDashboardStatsDto(
int TotalCafes,
int ActiveCafes,
int SuspendedCafes,
int OpenTickets,
int PlansConfigured);
public record UpdatePlanRequest(
string DisplayNameFa,
string? DisplayNameEn,
decimal MonthlyPriceToman,
bool IsBillableOnline,
bool IsActive,
int SortOrder,
PlanLimitsData Limits,
IReadOnlyList<string>? FeatureKeys);
public record UpdateSettingRequest(string Value, string? DescriptionFa);
public record UpdateFeatureRequest(
string DisplayNameFa,
string? DisplayNameEn,
string ModuleGroup,
bool IsEnabledGlobally);
public record AdminCafeListItemDto(
string Id,
string Name,
string Slug,
string City,
PlanTier PlanTier,
DateTime? PlanExpiresAt,
bool IsSuspended,
bool IsVerified,
int BranchCount,
int EmployeeCount,
DateTime CreatedAt);
public record AdminCafePatchRequest(
PlanTier? PlanTier,
DateTime? PlanExpiresAt,
bool? IsSuspended,
bool? IsVerified,
IReadOnlyList<string>? DiscoverBadges = null);
public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled);
+22
View File
@@ -0,0 +1,22 @@
using Meezi.Core.Constants;
namespace Meezi.Admin.API.Models;
public record SendOtpRequest(string Phone);
public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
DateTime ExpiresAt,
string UserId,
string CafeId,
string Role,
string PlanTier,
string Language,
string Actor = MeeziActorKinds.SystemAdmin);
public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
@@ -0,0 +1,23 @@
namespace Meezi.Admin.API.Models;
public record AdminCafeDiscoverProfileDto(
string CafeId,
string CafeName,
IReadOnlyList<string> Themes,
string? Size,
string? Floors,
IReadOnlyList<string> Vibes,
IReadOnlyList<string> Occasions,
IReadOnlyList<string> SpaceFeatures,
string? NoiseLevel,
string? PriceTier);
public record AdminUpsertCafeDiscoverProfileRequest(
IReadOnlyList<string>? Themes,
string? Size,
string? Floors,
IReadOnlyList<string>? Vibes,
IReadOnlyList<string>? Occasions,
IReadOnlyList<string>? SpaceFeatures,
string? NoiseLevel,
string? PriceTier);
@@ -0,0 +1,109 @@
namespace Meezi.Admin.API.Models;
public record GatewayCredentialsDto(
string? Username,
string? Password,
string? BranchCode,
string? TerminalCode,
string? ClientId,
string? ClientSecret,
string? BaseUrl,
bool HasStoredPassword,
bool HasStoredClientSecret);
public record PaymentGatewayConfigDto(
string Id,
string DisplayNameFa,
bool IsEnabled,
bool IsActive,
string? MerchantId,
string? ApiKey,
bool Sandbox,
bool HasStoredSecret,
GatewayCredentialsDto? Credentials = null);
public record KavenegarConfigDto(
bool IsEnabled,
string? ApiKey,
string OtpTemplate,
bool HasStoredApiKey);
public record OpenAiIntegrationConfigDto(
bool IsEnabled,
string? ApiKey,
string Model,
bool CoffeeAdvisorEnabled,
bool HasStoredApiKey);
public record MeshyIntegrationConfigDto(
bool IsEnabled,
string? ApiKey,
bool Menu3dEnabled,
bool HasStoredApiKey);
public record AiIntegrationsConfigDto(
OpenAiIntegrationConfigDto OpenAi,
MeshyIntegrationConfigDto Meshy);
public record PlatformIntegrationsDto(
string ActivePaymentGateway,
IReadOnlyList<PaymentGatewayConfigDto> PaymentGateways,
KavenegarConfigDto Kavenegar,
AiIntegrationsConfigDto Ai);
public record UpdatePlatformIntegrationsRequest(
string ActivePaymentGateway,
IReadOnlyList<UpdatePaymentGatewayRequest> PaymentGateways,
UpdateKavenegarRequest Kavenegar,
UpdateAiIntegrationsRequest Ai);
public record UpdateOpenAiIntegrationRequest(
bool IsEnabled,
string? ApiKey,
string Model,
bool CoffeeAdvisorEnabled);
public record UpdateMeshyIntegrationRequest(
bool IsEnabled,
string? ApiKey,
bool Menu3dEnabled);
public record UpdateAiIntegrationsRequest(
UpdateOpenAiIntegrationRequest OpenAi,
UpdateMeshyIntegrationRequest Meshy);
public record UpdatePaymentGatewayCredentialsRequest(
string? Username,
string? Password,
string? BranchCode,
string? TerminalCode,
string? ClientId,
string? ClientSecret,
string? BaseUrl);
public record UpdatePaymentGatewayRequest(
string Id,
bool IsEnabled,
string? MerchantId,
string? ApiKey,
bool Sandbox,
UpdatePaymentGatewayCredentialsRequest? Credentials = null);
public record UpdateKavenegarRequest(
bool IsEnabled,
string? ApiKey,
string OtpTemplate);
public record AdminNotificationRowDto(
string Id,
string CafeId,
string CafeName,
string Type,
string Title,
string? Body,
bool IsRead,
DateTime CreatedAt);
public record BroadcastNotificationRequest(string Title, string Body);
public record BroadcastNotificationResult(int CafeCount, int NotificationCount);
@@ -0,0 +1,11 @@
namespace Meezi.Admin.API.Models;
public record CafeNotificationDto(
string Id,
string Type,
string Title,
string? Body,
string? ReferenceId,
string? TableNumber,
bool IsRead,
DateTime CreatedAt);
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Meezi.Admin.API.Extensions;
using Meezi.Infrastructure.Data;
using Serilog;
namespace Meezi.Admin.API;
public class Program
{
public static async Task Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
builder.Services.AddMeeziAdminServices(builder.Configuration);
var app = builder.Build();
app.ConfigureMeeziAdminPipeline();
if (app.Configuration.GetValue<bool>("RUN_MIGRATIONS"))
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await DatabaseSchemaPatches.ApplyAsync(db);
}
if (!app.Configuration.GetValue<bool>("Testing:SkipSeed"))
await PlatformDataSeeder.SeedAsync(app.Services);
await app.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, "Admin API terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}
}
}
@@ -0,0 +1,15 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7210;http://localhost:5210",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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));
}
}
@@ -0,0 +1,24 @@
using FluentValidation;
using Meezi.Admin.API.Models;
using Meezi.Core.Utilities;
namespace Meezi.Admin.API.Validators;
public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
{
public SendOtpRequestValidator()
{
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile).WithMessage("Invalid phone number.");
}
}
public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
{
public VerifyOtpRequestValidator()
{
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile);
RuleFor(x => x.Code)
.Must(OtpNormalizer.IsValidSixDigitCode)
.WithMessage("OTP must be 6 digits.");
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5434;Database=meezi;Username=meezi;Password=meezi_local_pass",
"Redis": "localhost:6381"
},
"Jwt": {
"Key": "meezi-dev-secret-key-min-32-chars!!",
"Issuer": "meezi",
"Audience": "meezi-admin",
"AccessTokenExpiryDays": 7,
"RefreshTokenExpiryDays": 30
},
"Cors": {
"Origins": [
"http://localhost:3000",
"http://localhost:3101",
"http://localhost:3102",
"https://localhost:3000"
]
},
"Kavenegar": {
"ApiKey": "",
"OtpTemplate": "verify"
}
}