feat(plans): Stage 3b — DB-driven gates for reviews/styling/limits
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m51s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m51s
Make more plan rules read the admin-editable catalog instead of hardcoded values: - Review reply gated by the `review_reply` feature (Starter+) — 403 if not in plan. - Custom menu styling gated by `custom_menu_styling` (Starter+): only blocks an actual theme change, so a normal settings save re-sending the current theme is fine. - Menu categories/items limits now read catalog.GetLimitsAsync (Free categories editable; message no longer hardcodes a number). - Terminals limit reads the catalog (enforcement in TerminalRegistryService + the displayed max in TerminalsController). Remaining (small): menu watermark (Free shows it, `watermark_removed` removes it — needs the public-menu render), report-history (static ReportPlanGate) and AI-3D routing — these already enforce the correct matrix values, just not yet editable. 86 tests pass; build clean.
This commit is contained in:
@@ -2,7 +2,9 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
|
public CafeReviewsController(
|
||||||
|
IReviewService reviews,
|
||||||
|
IValidator<ReplyCafeReviewRequest> replyValidator,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_reviews = reviews;
|
_reviews = reviews;
|
||||||
_replyValidator = replyValidator;
|
_replyValidator = replyValidator;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
|
||||||
|
// Replying to reviews is a paid feature (Starter+).
|
||||||
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
|
||||||
|
|
||||||
var validation = await _replyValidator.ValidateAsync(request, ct);
|
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Infrastructure.Branding;
|
using Meezi.Infrastructure.Branding;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
|
public CafeSettingsController(
|
||||||
|
AppDbContext db,
|
||||||
|
IValidator<PatchCafeSettingsRequest> validator,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_validator = validator;
|
_validator = validator;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
||||||
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
||||||
if (request.Theme is not null)
|
if (request.Theme is not null)
|
||||||
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
{
|
||||||
|
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
|
||||||
|
// so a normal settings save that re-sends the current theme isn't rejected.
|
||||||
|
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||||
|
if (newThemeJson != cafe.ThemeJson)
|
||||||
|
{
|
||||||
|
var styleTier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
|
||||||
|
cafe.ThemeJson = newThemeJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (request.DefaultTaxRate is decimal taxRate)
|
if (request.DefaultTaxRate is decimal taxRate)
|
||||||
cafe.DefaultTaxRate = taxRate;
|
cafe.DefaultTaxRate = taxRate;
|
||||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
|
|||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
|
|||||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
private const string CategoryLimitMessage =
|
private const string CategoryLimitMessage =
|
||||||
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
"به سقف دستهبندی منوی پلن شما رسیدید. برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||||
private const string ItemLimitMessage =
|
private const string ItemLimitMessage =
|
||||||
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
|
||||||
public MenuController(
|
public MenuController(
|
||||||
IMenuService menuService,
|
IMenuService menuService,
|
||||||
IMenuAi3dGenerationService menuAi3d,
|
IMenuAi3dGenerationService menuAi3d,
|
||||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||||
IValidator<CreateMenuItemRequest> createItemValidator,
|
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||||
AppDbContext db)
|
AppDbContext db,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_menuService = menuService;
|
_menuService = menuService;
|
||||||
_menuAi3d = menuAi3d;
|
_menuAi3d = menuAi3d;
|
||||||
_createCategoryValidator = createCategoryValidator;
|
_createCategoryValidator = createCategoryValidator;
|
||||||
_createItemValidator = createItemValidator;
|
_createItemValidator = createItemValidator;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("categories")]
|
[HttpGet("categories")]
|
||||||
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var max = PlanLimits.MaxMenuCategories(tier);
|
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
|
||||||
if (max != int.MaxValue)
|
if (max != int.MaxValue)
|
||||||
{
|
{
|
||||||
var count = await _db.MenuCategories.CountAsync(
|
var count = await _db.MenuCategories.CountAsync(
|
||||||
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var max = PlanLimits.MaxMenuItems(tier);
|
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
|
||||||
if (max != int.MaxValue)
|
if (max != int.MaxValue)
|
||||||
{
|
{
|
||||||
var count = await _db.MenuItems.CountAsync(
|
var count = await _db.MenuItems.CountAsync(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.Core.Constants;
|
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
|
|||||||
public class TerminalsController : CafeApiControllerBase
|
public class TerminalsController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITerminalRegistryService _terminals;
|
private readonly ITerminalRegistryService _terminals;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
|
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
|
||||||
|
{
|
||||||
|
_terminals = terminals;
|
||||||
|
_catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register(
|
public async Task<IActionResult> Register(
|
||||||
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
var list = await _terminals.ListAsync(cafeId, ct);
|
var list = await _terminals.ListAsync(cafeId, ct);
|
||||||
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
|
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
|
||||||
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
||||||
private readonly IConnectionMultiplexer _redis;
|
private readonly IConnectionMultiplexer _redis;
|
||||||
|
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
|
public TerminalRegistryService(
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||||
|
{
|
||||||
|
_redis = redis;
|
||||||
|
_catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
@@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService
|
|||||||
terminalId = terminalId.Trim();
|
terminalId = terminalId.Trim();
|
||||||
var db = _redis.GetDatabase();
|
var db = _redis.GetDatabase();
|
||||||
var setKey = $"terminals:{cafeId}";
|
var setKey = $"terminals:{cafeId}";
|
||||||
var max = PlanLimits.MaxTerminals(tier);
|
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals;
|
||||||
|
|
||||||
if (max == int.MaxValue)
|
if (max == int.MaxValue)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user