From 2487f9e30fa8c9ae7fcd0180258073483a0d8536 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 01:40:00 +0330 Subject: [PATCH] =?UTF-8?q?feat(plans):=20Stage=203b=20=E2=80=94=20DB-driv?= =?UTF-8?q?en=20gates=20for=20reviews/styling/limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Controllers/CafeReviewsController.cs | 16 ++++++++++++- .../Controllers/CafeSettingsController.cs | 23 +++++++++++++++++-- src/Meezi.API/Controllers/MenuController.cs | 14 +++++++---- .../Controllers/TerminalsController.cs | 11 ++++++--- .../Services/TerminalRegistryService.cs | 11 +++++++-- 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/Meezi.API/Controllers/CafeReviewsController.cs b/src/Meezi.API/Controllers/CafeReviewsController.cs index 81ce30a..c0e21db 100644 --- a/src/Meezi.API/Controllers/CafeReviewsController.cs +++ b/src/Meezi.API/Controllers/CafeReviewsController.cs @@ -2,7 +2,9 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Meezi.API.Models.Public; using Meezi.API.Services; +using Meezi.Core.Enums; using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Services.Platform; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase { private readonly IReviewService _reviews; private readonly IValidator _replyValidator; + private readonly IPlatformCatalogService _catalog; - public CafeReviewsController(IReviewService reviews, IValidator replyValidator) + public CafeReviewsController( + IReviewService reviews, + IValidator replyValidator, + IPlatformCatalogService catalog) { _reviews = reviews; _replyValidator = replyValidator; + _catalog = catalog; } [HttpGet] @@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase CancellationToken ct = default) { 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(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); if (!validation.IsValid) { diff --git a/src/Meezi.API/Controllers/CafeSettingsController.cs b/src/Meezi.API/Controllers/CafeSettingsController.cs index 53f6466..211253a 100644 --- a/src/Meezi.API/Controllers/CafeSettingsController.cs +++ b/src/Meezi.API/Controllers/CafeSettingsController.cs @@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Cafes; using Meezi.API.Services; +using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Branding; using Meezi.Infrastructure.Data; +using Meezi.Infrastructure.Services.Platform; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase { private readonly AppDbContext _db; private readonly IValidator _validator; + private readonly IPlatformCatalogService _catalog; - public CafeSettingsController(AppDbContext db, IValidator validator) + public CafeSettingsController( + AppDbContext db, + IValidator validator, + IPlatformCatalogService catalog) { _db = db; _validator = validator; + _catalog = catalog; } [HttpGet] @@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim(); if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim(); 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(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) cafe.DefaultTaxRate = taxRate; if (request.AllowBranchTaxOverride is bool allowTax) diff --git a/src/Meezi.API/Controllers/MenuController.cs b/src/Meezi.API/Controllers/MenuController.cs index d399ed2..62b8430 100644 --- a/src/Meezi.API/Controllers/MenuController.cs +++ b/src/Meezi.API/Controllers/MenuController.cs @@ -7,6 +7,7 @@ using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; +using Meezi.Infrastructure.Services.Platform; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase private readonly IValidator _createCategoryValidator; private readonly IValidator _createItemValidator; private readonly AppDbContext _db; + private readonly IPlatformCatalogService _catalog; private const string CategoryLimitMessage = - "محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید."; + "به سقف دسته‌بندی منوی پلن شما رسیدید. برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید."; private const string ItemLimitMessage = - "محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید."; + "به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید."; public MenuController( IMenuService menuService, IMenuAi3dGenerationService menuAi3d, IValidator createCategoryValidator, IValidator createItemValidator, - AppDbContext db) + AppDbContext db, + IPlatformCatalogService catalog) { _menuService = menuService; _menuAi3d = menuAi3d; _createCategoryValidator = createCategoryValidator; _createItemValidator = createItemValidator; _db = db; + _catalog = catalog; } [HttpGet("categories")] @@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase if (!validation.IsValid) return BadRequest(ValidationError(validation)); var tier = tenant.PlanTier ?? PlanTier.Free; - var max = PlanLimits.MaxMenuCategories(tier); + var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories; if (max != int.MaxValue) { var count = await _db.MenuCategories.CountAsync( @@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase if (!validation.IsValid) return BadRequest(ValidationError(validation)); var tier = tenant.PlanTier ?? PlanTier.Free; - var max = PlanLimits.MaxMenuItems(tier); + var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems; if (max != int.MaxValue) { var count = await _db.MenuItems.CountAsync( diff --git a/src/Meezi.API/Controllers/TerminalsController.cs b/src/Meezi.API/Controllers/TerminalsController.cs index 7ddf253..f42b042 100644 --- a/src/Meezi.API/Controllers/TerminalsController.cs +++ b/src/Meezi.API/Controllers/TerminalsController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; -using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.API.Services; +using Meezi.Infrastructure.Services.Platform; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -11,8 +11,13 @@ namespace Meezi.API.Controllers; public class TerminalsController : CafeApiControllerBase { 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")] public async Task Register( @@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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(true, new { terminals = list, max })); } diff --git a/src/Meezi.API/Services/TerminalRegistryService.cs b/src/Meezi.API/Services/TerminalRegistryService.cs index bf40781..02281ea 100644 --- a/src/Meezi.API/Services/TerminalRegistryService.cs +++ b/src/Meezi.API/Services/TerminalRegistryService.cs @@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService { private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90); 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( string cafeId, @@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService terminalId = terminalId.Trim(); var db = _redis.GetDatabase(); var setKey = $"terminals:{cafeId}"; - var max = PlanLimits.MaxTerminals(tier); + var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals; if (max == int.MaxValue) {