feat(plans): report-history + AI-3D limits read from the catalog (S3 finish)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled

The last two limits that still read hardcoded PlanLimits now come from the
admin-editable catalog, so editing them in the admin panel takes effect:

- ReportPlanGate is now limit-driven (takes int maxDays, not a tier); ReportsController
  resolves MaxReportHistoryDays from catalog.GetLimitsAsync. LimitMessage is generic
  (reflects the actual days). EnsureReportDateAllowed is now async.
- MenuAi3dGenerationService.ResolveLimitAsync reads MaxMenuAi3dPerMonth from the catalog.

Every plan limit + feature gate is now DB-driven and admin-editable. 86 tests pass.
This commit is contained in:
soroush.asadi
2026-06-03 06:57:59 +03:30
parent dcdb0d5747
commit e46d833371
3 changed files with 34 additions and 35 deletions
+22 -14
View File
@@ -4,6 +4,7 @@ using Meezi.API.Services;
using Meezi.API.Utils;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -13,13 +14,21 @@ public class ReportsController : CafeApiControllerBase
{
private readonly IReportService _reports;
private readonly IDailyReportService _dailyReports;
private readonly IPlatformCatalogService _catalog;
public ReportsController(IReportService reports, IDailyReportService dailyReports)
public ReportsController(
IReportService reports,
IDailyReportService dailyReports,
IPlatformCatalogService catalog)
{
_reports = reports;
_dailyReports = dailyReports;
_catalog = catalog;
}
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
[HttpGet("daily")]
public async Task<IActionResult> GetDailySnapshot(
string cafeId,
@@ -37,7 +46,7 @@ public class ReportsController : CafeApiControllerBase
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
if (await EnsureReportDateAllowedAsync(tenant, reportDate, ct) is { } planError) return planError;
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
if (snapshot is null)
@@ -62,16 +71,16 @@ public class ReportsController : CafeApiControllerBase
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
var today = IranCalendar.TodayInIran;
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
}
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
if (clamped is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
@@ -91,12 +100,11 @@ public class ReportsController : CafeApiControllerBase
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
if (days > maxDays && maxDays != int.MaxValue)
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "days")));
}
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
@@ -180,14 +188,14 @@ public class ReportsController : CafeApiControllerBase
return DateOnly.TryParse(value, out date);
}
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
private async Task<IActionResult?> EnsureReportDateAllowedAsync(ITenantContext tenant, DateOnly date, CancellationToken ct)
{
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
var today = IranCalendar.TodayInIran;
if (ReportPlanGate.IsDateInRange(tier, date, today))
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
return null;
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
}
}