feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user