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
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:
@@ -4,6 +4,7 @@ using Meezi.API.Services;
|
|||||||
using Meezi.API.Utils;
|
using Meezi.API.Utils;
|
||||||
using Meezi.Core.Enums;
|
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;
|
||||||
@@ -13,13 +14,21 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IReportService _reports;
|
private readonly IReportService _reports;
|
||||||
private readonly IDailyReportService _dailyReports;
|
private readonly IDailyReportService _dailyReports;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
public ReportsController(
|
||||||
|
IReportService reports,
|
||||||
|
IDailyReportService dailyReports,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_reports = reports;
|
_reports = reports;
|
||||||
_dailyReports = dailyReports;
|
_dailyReports = dailyReports;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
|
||||||
|
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
|
||||||
|
|
||||||
[HttpGet("daily")]
|
[HttpGet("daily")]
|
||||||
public async Task<IActionResult> GetDailySnapshot(
|
public async Task<IActionResult> GetDailySnapshot(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
@@ -37,7 +46,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
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);
|
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||||
if (snapshot is null)
|
if (snapshot is null)
|
||||||
@@ -62,16 +71,16 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||||
|
|
||||||
var today = IranCalendar.TodayInIran;
|
var today = IranCalendar.TodayInIran;
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||||
|
|
||||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|
||||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
|
||||||
{
|
{
|
||||||
return StatusCode(403, new ApiResponse<object>(false, 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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
|
||||||
if (clamped is null)
|
if (clamped is null)
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||||
@@ -91,12 +100,11 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
|
||||||
if (days > maxDays && maxDays != int.MaxValue)
|
if (days > maxDays && maxDays != int.MaxValue)
|
||||||
{
|
{
|
||||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
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);
|
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||||
@@ -180,14 +188,14 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
return DateOnly.TryParse(value, out date);
|
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;
|
var today = IranCalendar.TodayInIran;
|
||||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return StatusCode(403, new ApiResponse<object>(false, 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")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public class MenuAi3dGenerationService : IMenuAi3dGenerationService
|
|||||||
{
|
{
|
||||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
||||||
return 0;
|
return 0;
|
||||||
return PlanLimits.MaxMenuAi3dPerMonth(planTier);
|
return (await _catalog.GetLimitsAsync(planTier, cancellationToken)).MaxMenuAi3dPerMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string UsageKey(string cafeId) =>
|
private static string UsageKey(string cafeId) =>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
using Meezi.Core.Constants;
|
|
||||||
using Meezi.Core.Enums;
|
|
||||||
|
|
||||||
namespace Meezi.API.Services;
|
namespace Meezi.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report-history window checks. Takes the (admin-editable) max-history days
|
||||||
|
/// directly so the limit comes from the plan catalog, not a hardcoded tier table.
|
||||||
|
/// </summary>
|
||||||
public static class ReportPlanGate
|
public static class ReportPlanGate
|
||||||
{
|
{
|
||||||
public static bool IsDateInRange(PlanTier tier, DateOnly date, DateOnly todayIran)
|
public static bool IsDateInRange(int maxDays, DateOnly date, DateOnly todayIran)
|
||||||
{
|
{
|
||||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
|
||||||
if (maxDays == int.MaxValue)
|
if (maxDays == int.MaxValue)
|
||||||
return date <= todayIran;
|
return date <= todayIran;
|
||||||
|
|
||||||
@@ -16,16 +16,15 @@ public static class ReportPlanGate
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static (DateOnly From, DateOnly To)? ClampRange(
|
public static (DateOnly From, DateOnly To)? ClampRange(
|
||||||
PlanTier tier,
|
int maxDays,
|
||||||
DateOnly from,
|
DateOnly from,
|
||||||
DateOnly to,
|
DateOnly to,
|
||||||
DateOnly todayIran)
|
DateOnly todayIran)
|
||||||
{
|
{
|
||||||
if (from > to) return null;
|
if (from > to) return null;
|
||||||
if (!IsDateInRange(tier, to, todayIran) || !IsDateInRange(tier, from, todayIran))
|
if (!IsDateInRange(maxDays, to, todayIran) || !IsDateInRange(maxDays, from, todayIran))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
|
||||||
if (maxDays == int.MaxValue)
|
if (maxDays == int.MaxValue)
|
||||||
return (from, to);
|
return (from, to);
|
||||||
|
|
||||||
@@ -36,16 +35,8 @@ public static class ReportPlanGate
|
|||||||
return (clampedFrom, clampedTo);
|
return (clampedFrom, clampedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string LimitMessage(PlanTier tier)
|
public static string LimitMessage(int maxDays) =>
|
||||||
{
|
maxDays == int.MaxValue
|
||||||
var days = PlanLimits.MaxReportHistoryDays(tier);
|
? "Report date is outside the allowed range."
|
||||||
return tier switch
|
: $"Daily reports on your plan are limited to the last {maxDays} days. Upgrade for more history.";
|
||||||
{
|
|
||||||
PlanTier.Free =>
|
|
||||||
"Daily reports on the Free plan are limited to today and the previous 7 days. Upgrade to Pro for 90 days of history.",
|
|
||||||
PlanTier.Pro =>
|
|
||||||
"Daily reports on the Pro plan are limited to the last 90 days. Upgrade to Business for unlimited history.",
|
|
||||||
_ => "Report date is outside your plan range."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user