Browse Source

Referral program (#933)

* Referral program.

* Fixes.

* Fix noop manager.
pull/934/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
131db355ae
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/frontend_zh.json
  5. 2
      backend/i18n/source/frontend_en.json
  6. 8
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  7. 8
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  8. 6
      backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs
  10. 12
      backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  12. 8
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  13. 14
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  14. 14
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  15. 8
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  16. 59
      backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  17. 31
      backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs
  18. 43
      backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs
  19. 25
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  20. 2
      backend/src/Squidex/Config/Domain/SubscriptionServices.cs
  21. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs
  22. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs
  23. 12
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs
  24. 24
      backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
  25. 9
      frontend/src/app/features/settings/pages/plans/plans-page.component.html
  26. 9
      frontend/src/app/features/teams/pages/plans/plans-page.component.html
  27. 14
      frontend/src/app/features/teams/state/team-plans.state.ts
  28. 4
      frontend/src/app/shared/services/plans.service.spec.ts
  29. 6
      frontend/src/app/shared/services/shared.ts
  30. 14
      frontend/src/app/shared/state/plans.state.ts

2
backend/i18n/frontend_en.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per Month",
"plans.perYear": "Per Year",
"plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Refresh Plans",
"plans.reloaded": "Plans reloaded.",
"plans.selected": "Selected",

2
backend/i18n/frontend_it.json

@ -629,6 +629,8 @@
"plans.perMonth": "Al mese",
"plans.perYear": "all'anno",
"plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Aggiorna i piani",
"plans.reloaded": "Piano aggiornati.",
"plans.selected": "Selezionato",

2
backend/i18n/frontend_nl.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per maand",
"plans.perYear": "Per jaar",
"plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Plannen vernieuwen",
"plans.reloaded": "Plans reloaded.",
"plans.selected": "Geselecteerd",

2
backend/i18n/frontend_zh.json

@ -629,6 +629,8 @@
"plans.perMonth": "每月",
"plans.perYear": "每年",
"plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "刷新计划",
"plans.reloaded": "计划重新加载。",
"plans.selected": "已选择",

2
backend/i18n/source/frontend_en.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per Month",
"plans.perYear": "Per Year",
"plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Refresh Plans",
"plans.reloaded": "Plans reloaded.",
"plans.selected": "Selected",

8
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetLoader assetLoader;
private readonly ISnapshotStore<State> store;
private readonly ITagService tagService;
private readonly IAppUsageGate appUsageGate;
private readonly IUsageGate usageGate;
[CollectionName("Index_TagHistory")]
public sealed class State
@ -27,10 +27,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
public HashSet<string>? Tags { get; set; }
}
public AssetUsageTracker(IAppUsageGate appUsageGate, IAssetLoader assetLoader, ITagService tagService,
public AssetUsageTracker(IUsageGate usageGate, IAssetLoader assetLoader, ITagService tagService,
ISnapshotStore<State> store)
{
this.appUsageGate = appUsageGate;
this.usageGate = usageGate;
this.assetLoader = assetLoader;
this.tagService = tagService;
this.store = store;
@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
return appUsageGate.DeleteAssetUsageAsync(app.Id, ct);
return usageGate.DeleteAssetUsageAsync(app.Id, ct);
}
}
}

8
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await store.ClearAsync();
await appUsageGate.DeleteAssetsUsageAsync();
await usageGate.DeleteAssetsUsageAsync();
}
public async Task On(IEnumerable<Envelope<IEvent>> events)
@ -185,13 +185,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (@event.Payload)
{
case AssetCreated assetCreated:
return appUsageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1);
return usageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1);
case AssetUpdated assetUpdated:
return appUsageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
return usageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
case AssetDeleted assetDeleted:
return appUsageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1);
return usageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1);
}
return Task.CompletedTask;

6
backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs

@ -18,6 +18,12 @@ namespace Squidex.Domain.Apps.Entities.Billing
Task<Uri?> GetPortalLinkAsync(string userId, ITeamEntity team,
CancellationToken ct = default);
Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, IAppEntity app,
CancellationToken ct = default);
Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, ITeamEntity team,
CancellationToken ct = default);
Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default);

2
backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Billing
{
public interface IAppUsageGate
public interface IUsageGate
{
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
CancellationToken ct = default);

12
backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs

@ -24,6 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Billing
return Task.FromResult<Uri?>(null);
}
public Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, IAppEntity app,
CancellationToken ct = default)
{
return Task.FromResult<(string? Code, double AmountEarned)>((null, 0));
}
public Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, ITeamEntity team,
CancellationToken ct = default)
{
return Task.FromResult<(string? Code, double AmountEarned)>((null, 0));
}
public Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default)
{

2
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs

@ -17,7 +17,7 @@ using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed class UsageGate : IAppUsageGate, IAssetUsageTracker
public sealed class UsageGate : IUsageGate, IAssetUsageTracker
{
private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize";

8
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -15,11 +15,11 @@ namespace Squidex.Web.Pipeline
{
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{
private readonly IAppUsageGate appUsageGate;
private readonly IUsageGate usageGate;
public ApiCostsFilter(IAppUsageGate appUsageGate)
public ApiCostsFilter(IUsageGate usageGate)
{
this.appUsageGate = appUsageGate;
this.usageGate = usageGate;
}
IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@ -50,7 +50,7 @@ namespace Squidex.Web.Pipeline
{
var (_, clientId) = context.HttpContext.User.GetClient();
var isBlocked = await appUsageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted);
var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted);
if (isBlocked)
{

14
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -17,15 +17,15 @@ namespace Squidex.Web.Pipeline
{
public sealed class UsageMiddleware : IMiddleware
{
private readonly IAppLogStore appUsageLog;
private readonly IAppUsageGate appUsageGate;
private readonly IAppLogStore usageLog;
private readonly IUsageGate usageGate;
public IClock Clock { get; set; } = SystemClock.Instance;
public UsageMiddleware(IAppLogStore appUsageLog, IAppUsageGate appUsageGate)
public UsageMiddleware(IAppLogStore usageLog, IUsageGate usageGate)
{
this.appUsageLog = appUsageLog;
this.appUsageGate = appUsageGate;
this.usageLog = usageLog;
this.usageGate = usageGate;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@ -69,13 +69,13 @@ namespace Squidex.Web.Pipeline
request.UserClientId = clientId;
// Do not flow cancellation token because it is too important.
await appUsageLog.LogAsync(app.Id, request, default);
await usageLog.LogAsync(app.Id, request, default);
if (request.Costs > 0)
{
var date = request.Timestamp.ToDateTimeUtc().Date;
await appUsageGate.TrackRequestAsync(app, request.UserClientId, date,
await usageGate.TrackRequestAsync(app, request.UserClientId, date,
request.Costs,
request.ElapsedMs,
request.Bytes,

14
backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -25,14 +25,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppContributorsController : ApiController
{
private readonly IAppUsageGate usageTracker;
private readonly IUserResolver usageGate;
private readonly IUsageGate usageGate;
private readonly IUserResolver userResolver;
public AppContributorsController(ICommandBus commandBus, IAppUsageGate usageGate, IUserResolver userResolver)
public AppContributorsController(ICommandBus commandBus, IUsageGate usageGate, IUserResolver userResolver)
: base(commandBus)
{
this.usageTracker = usageGate;
this.usageGate = userResolver;
this.usageGate = usageGate;
this.userResolver = userResolver;
}
/// <summary>
@ -145,9 +145,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
private async Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited)
{
var (plan, _, _) = await usageTracker.GetPlanForAppAsync(app, HttpContext.RequestAborted);
var (plan, _, _) = await usageGate.GetPlanForAppAsync(app, HttpContext.RequestAborted);
return await ContributorsDto.FromDomainAsync(app, Resources, usageGate, plan, invited);
return await ContributorsDto.FromDomainAsync(app, Resources, userResolver, plan, invited);
}
}
}

8
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -32,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiExplorerSettings(GroupName = nameof(Assets))]
public sealed class AssetsController : ApiController
{
private readonly IAppUsageGate appUsageGate;
private readonly IUsageGate usageGate;
private readonly IAssetQueryService assetQuery;
private readonly IAssetUsageTracker assetUsageTracker;
private readonly ITagService tagService;
@ -40,14 +40,14 @@ namespace Squidex.Areas.Api.Controllers.Assets
public AssetsController(
ICommandBus commandBus,
IAppUsageGate appUsageGate,
IUsageGate usageGate,
IAssetQueryService assetQuery,
IAssetUsageTracker assetUsageTracker,
ITagService tagService,
AssetTusRunner assetTusRunner)
: base(commandBus)
{
this.appUsageGate = appUsageGate;
this.usageGate = usageGate;
this.assetQuery = assetQuery;
this.assetUsageTracker = assetUsageTracker;
this.assetTusRunner = assetTusRunner;
@ -469,7 +469,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
throw new ValidationException(error);
}
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var currentSize = await assetUsageTracker.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted);

59
backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared;
using Squidex.Web;
@ -25,17 +26,17 @@ namespace Squidex.Areas.Api.Controllers.Plans
{
private readonly IBillingPlans billingPlans;
private readonly IBillingManager billingManager;
private readonly IAppUsageGate appUsageGate;
private readonly IUsageGate usageGate;
public AppPlansController(ICommandBus commandBus,
IAppUsageGate appUsageGate,
IUsageGate usageGate,
IBillingPlans billingPlans,
IBillingManager billingManager)
: base(commandBus)
{
this.billingPlans = billingPlans;
this.billingManager = billingManager;
this.appUsageGate = appUsageGate;
this.usageGate = usageGate;
}
/// <summary>
@ -55,34 +56,42 @@ namespace Squidex.Areas.Api.Controllers.Plans
{
var response = Deferred.AsyncResponse(async () =>
{
var (_, planId, teamId) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var plans = billingPlans.GetAvailablePlans();
var owner = App.Plan?.Owner.Identifier;
var isOwner = string.Equals(owner, UserId, StringComparison.Ordinal);
var isLocked = PlansLockedReason.None;
var linkUrl = (Uri?)null;
var (plan, link, referral) =
await AsyncHelper.WhenAll(
usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted),
billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted),
billingManager.GetReferralCodeAsync(UserId, App, HttpContext.RequestAborted));
if (teamId != null)
{
isLocked = PlansLockedReason.ManagedByTeam;
}
else if (!Resources.CanChangePlan)
{
isLocked = PlansLockedReason.NoPermission;
}
else if (owner != null && !isOwner)
{
isLocked = PlansLockedReason.NotOwner;
}
var planOwner = App.Plan?.Owner.Identifier;
if (isLocked == PlansLockedReason.None || isOwner)
PlansLockedReason GetLocked()
{
linkUrl = await billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted);
if (plan.TeamId != null)
{
return PlansLockedReason.ManagedByTeam;
}
else if (!Resources.CanChangePlan)
{
return PlansLockedReason.NoPermission;
}
else if (planOwner != null && !string.Equals(planOwner, UserId, StringComparison.Ordinal))
{
return PlansLockedReason.NotOwner;
}
return PlansLockedReason.None;
}
var plans = billingPlans.GetAvailablePlans();
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, isLocked);
return PlansDto.FromDomain(
plans.ToArray(),
planOwner,
plan.PlanId,
referral.Code,
referral.AmountEarned,
link,
GetLocked());
});
Response.Headers[HeaderNames.ETag] = App.ToEtag();

31
backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs

@ -31,24 +31,47 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models
/// <summary>
/// The link to the management portal.
/// </summary>
public string? PortalLink { get; set; }
public Uri? PortalLink { get; set; }
/// <summary>
/// The code for referral managemenent.
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// The amount earned.
/// </summary>
public string? ReferralEarned { get; set; }
/// <summary>
/// The reason why the plan cannot be changed.
/// </summary>
public PlansLockedReason Locked { get; set; }
public static PlansDto FromDomain(Plan[] plans, string? owner, string planId, Uri? portalLink, PlansLockedReason locked)
public static PlansDto FromDomain(
Plan[] plans,
string? planOwner,
string? planId,
string? referralCode,
double? referralAmount,
Uri? portalLink,
PlansLockedReason locked)
{
var result = new PlansDto
{
Locked = locked,
CurrentPlanId = planId,
Plans = plans.Select(PlanDto.FromDomain).ToArray(),
PlanOwner = owner,
PortalLink = portalLink?.ToString()
PlanOwner = planOwner,
ReferralCode = referralCode,
ReferralEarned = $"{referralAmount ?? 0:0.00} EUR"
};
if (locked == PlansLockedReason.None)
{
result.PortalLink = portalLink;
}
return result;
}
}

43
backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.Teams.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared;
using Squidex.Web;
@ -23,12 +24,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ApiExplorerSettings(GroupName = nameof(Plans))]
public sealed class TeamPlansController : ApiController
{
private readonly IAppUsageGate appUsageGate;
private readonly IUsageGate appUsageGate;
private readonly IBillingPlans billingPlans;
private readonly IBillingManager billingManager;
public TeamPlansController(ICommandBus commandBus,
IAppUsageGate appUsageGate,
IUsageGate appUsageGate,
IBillingPlans billingPlans,
IBillingManager billingManager)
: base(commandBus)
@ -55,31 +56,31 @@ namespace Squidex.Areas.Api.Controllers.Plans
{
var response = Deferred.AsyncResponse(async () =>
{
var owner = Team.Plan?.Owner.Identifier;
var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var plans = billingPlans.GetAvailablePlans();
var lockedReason = PlansLockedReason.None;
var (plan, link, referral) =
await AsyncHelper.WhenAll(
appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted),
billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted),
billingManager.GetReferralCodeAsync(UserId, Team, HttpContext.RequestAborted));
if (!Resources.CanChangeTeamPlan)
PlansLockedReason GetLocked()
{
lockedReason = PlansLockedReason.NoPermission;
}
else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase))
{
lockedReason = PlansLockedReason.NotOwner;
}
if (!Resources.CanChangeTeamPlan)
{
return PlansLockedReason.NoPermission;
}
var linkUrl = (Uri?)null;
if (lockedReason == PlansLockedReason.None)
{
linkUrl = await billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted);
return PlansLockedReason.None;
}
var plans = billingPlans.GetAvailablePlans();
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason);
return PlansDto.FromDomain(
plans.ToArray(), null,
plan.PlanId,
referral.Code,
referral.AmountEarned,
link,
GetLocked());
});
Response.Headers[HeaderNames.ETag] = Team.ToEtag();

25
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -27,8 +27,8 @@ namespace Squidex.Areas.Api.Controllers.Statistics
public sealed class UsagesController : ApiController
{
private readonly IApiUsageTracker usageTracker;
private readonly IAppLogStore appLogStore;
private readonly IAppUsageGate appUsageGate;
private readonly IAppLogStore usageLog;
private readonly IUsageGate usageGate;
private readonly IAssetUsageTracker assetStatsRepository;
private readonly IDataProtector dataProtector;
private readonly IUrlGenerator urlGenerator;
@ -37,18 +37,17 @@ namespace Squidex.Areas.Api.Controllers.Statistics
ICommandBus commandBus,
IDataProtectionProvider dataProtection,
IApiUsageTracker usageTracker,
IAppLogStore appLogStore,
IAppUsageGate appUsageGate,
IAppLogStore usageLog,
IUsageGate usageGate,
IAssetUsageTracker assetStatsRepository,
IUrlGenerator urlGenerator)
: base(commandBus)
{
this.usageTracker = usageTracker;
this.appLogStore = appLogStore;
this.appUsageGate = appUsageGate;
this.usageLog = usageLog;
this.assetStatsRepository = assetStatsRepository;
this.urlGenerator = urlGenerator;
this.usageGate = usageGate;
this.usageTracker = usageTracker;
dataProtector = dataProtection.CreateProtector("LogToken");
}
@ -91,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var callback = new FileCallback((body, range, ct) =>
{
return appLogStore.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct);
return usageLog.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct);
});
return new FileCallbackResult("text/csv", callback)
@ -127,7 +126,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted);
// Use the current app plan to show the limits to the user.
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details);
@ -161,7 +160,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var (summary, details) = await usageTracker.QueryAsync(TeamId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted);
// Use the current team plan to show the limits to the user.
var (plan, _) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details);
@ -186,7 +185,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var size = await assetStatsRepository.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted);
// Use the current app plan to show the limits to the user.
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize };
@ -211,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var size = await assetStatsRepository.GetTotalSizeByTeamAsync(TeamId, HttpContext.RequestAborted);
// Use the current team plan to show the limits to the user.
var (plan, _) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize };

2
backend/src/Squidex/Config/Domain/SubscriptionServices.cs

@ -26,7 +26,7 @@ namespace Squidex.Config.Domain
.AsOptional<IBillingManager>();
services.AddSingletonAs<UsageGate>()
.AsOptional<IAppUsageGate>().As<IAssetUsageTracker>();
.AsOptional<IUsageGate>().As<IAssetUsageTracker>();
}
}
}

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs

@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>();
private readonly ISnapshotStore<AssetUsageTracker.State> store = A.Fake<ISnapshotStore<AssetUsageTracker.State>>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>();
private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly DomainId assetId = DomainId.NewGuid();
private readonly DomainId assetKey;
@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
assetKey = DomainId.Combine(appId, assetId);
sut = new AssetUsageTracker(appUsageGate, assetLoader, tagService, store);
sut = new AssetUsageTracker(usageGate, assetLoader, tagService, store);
}
[Fact]
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await sut.On(new[] { envelope });
A.CallTo(() => appUsageGate.TrackAssetAsync(appId.Id, date, sizeDiff, countDiff, default))
A.CallTo(() => usageGate.TrackAssetAsync(appId.Id, date, sizeDiff, countDiff, default))
.MustHaveHappened();
}

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs

@ -55,6 +55,22 @@ namespace Squidex.Domain.Apps.Entities.Billing
Assert.Null(actual);
}
[Fact]
public async Task Should_not_return_referral_code_for_app()
{
var actual = await sut.GetReferralCodeAsync(null!, (IAppEntity)null!);
Assert.Equal((null, 00), actual);
}
[Fact]
public async Task Should_not_return_referral_code_for_team()
{
var actual = await sut.GetReferralCodeAsync(null!, (ITeamEntity)null!);
Assert.Equal((null, 0), actual);
}
[Fact]
public async Task Should_do_nothing_if_checking_for_redirect_for_app()
{

12
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -21,7 +21,7 @@ namespace Squidex.Web.Pipeline
public class ApiCostsFilterTests
{
private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>();
private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly ActionExecutingContext actionContext;
private readonly ActionExecutionDelegate next;
private readonly HttpContext httpContext = new DefaultHttpContext();
@ -43,7 +43,7 @@ namespace Squidex.Web.Pipeline
return Task.FromResult<ActionExecutedContext>(null!);
};
sut = new ApiCostsFilter(appUsageGate);
sut = new ApiCostsFilter(usageGate);
}
[Fact]
@ -53,7 +53,7 @@ namespace Squidex.Web.Pipeline
SetupApp();
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.Returns(true);
await sut.OnActionExecutionAsync(actionContext, next);
@ -69,7 +69,7 @@ namespace Squidex.Web.Pipeline
SetupApp();
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.Returns(false);
await sut.OnActionExecutionAsync(actionContext, next);
@ -88,7 +88,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled);
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.MustNotHaveHappened();
}
@ -101,7 +101,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled);
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.MustNotHaveHappened();
}

24
backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs

@ -19,8 +19,8 @@ namespace Squidex.Web.Pipeline
{
public class UsageMiddlewareTests
{
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>();
private readonly IAppLogStore usageLog = A.Fake<IAppLogStore>();
private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly IClock clock = A.Fake<IClock>();
private readonly Instant instant = SystemClock.Instance.GetCurrentInstant();
private readonly HttpContext httpContext = new DefaultHttpContext();
@ -41,7 +41,7 @@ namespace Squidex.Web.Pipeline
return Task.CompletedTask;
};
sut = new UsageMiddleware(appLogStore, appUsageGate)
sut = new UsageMiddleware(usageLog, usageGate)
{
Clock = clock
};
@ -56,7 +56,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(A<IAppEntity>._, A<string>._, A<DateTime>._, A<double>._, A<long>._, A<long>._, default))
A.CallTo(() => usageGate.TrackRequestAsync(A<IAppEntity>._, A<string>._, A<DateTime>._, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened();
}
@ -76,7 +76,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened();
}
@ -94,7 +94,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, A<long>._, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, A<long>._, default))
.MustHaveHappened();
}
@ -113,7 +113,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 1024, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 1024, default))
.MustHaveHappened();
}
@ -136,7 +136,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened();
}
@ -159,7 +159,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened();
}
@ -192,7 +192,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened();
}
@ -210,7 +210,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened();
}
@ -227,7 +227,7 @@ namespace Squidex.Web.Pipeline
await sut.InvokeAsync(httpContext, next);
A.CallTo(() => appLogStore.LogAsync(appId.Id,
A.CallTo(() => usageLog.LogAsync(appId.Id,
A<RequestLog>.That.Matches(x =>
x.Timestamp == instant &&
x.RequestMethod == "GET" &&

9
frontend/src/app/features/settings/pages/plans/plans-page.component.html

@ -20,6 +20,15 @@
{{ 'plans.managedByTeam' | sqxTranslate }}
</div>
<div class="card card-body mb-4" *ngIf="plansState.referralCode | async">
<h5 class="card-title">Squidex Referal Program</h5>
<sqx-form-hint>
<div [innerHTML]="'plans.referralHint' | sqxTranslate: { code: plansState.snapshot.referralCode } | sqxMarkdown | sqxSafeHtml"></div>
<div [innerHTML]="'plans.referralEarned' | sqxTranslate: { amount: plansState.snapshot.referralEarned } | sqxMarkdown | sqxSafeHtml"></div>
</sqx-form-hint>
</div>
<div>
<div class="text-muted text-center empty" *ngIf="plans.length === 0">
{{ 'plans.noPlanConfigured' | sqxTranslate }}

9
frontend/src/app/features/teams/pages/plans/plans-page.component.html

@ -16,6 +16,15 @@
{{ 'plans.allApps' | sqxTranslate }}
</div>
<div class="card card-body mb-4" *ngIf="plansState.referralCode | async">
<h5 class="card-title">Squidex Referal Program</h5>
<sqx-form-hint>
<div [innerHTML]="'plans.referralHint' | sqxTranslate: { code: plansState.snapshot.referralCode } | sqxMarkdown | sqxSafeHtml"></div>
<div [innerHTML]="'plans.referralEarned' | sqxTranslate: { amount: plansState.snapshot.referralEarned } | sqxMarkdown | sqxSafeHtml"></div>
</sqx-form-hint>
</div>
<div>
<div class="text-muted text-center empty" *ngIf="plans.length === 0">
{{ 'plans.noPlanConfigured' | sqxTranslate }}

14
frontend/src/app/features/teams/state/team-plans.state.ts

@ -32,6 +32,12 @@ interface Snapshot extends LoadingState {
// The portal link if available.
portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed.
locked?: PlanLockedReason;
@ -56,6 +62,12 @@ export class TeamPlansState extends State<Snapshot> {
public locked =
this.project(x => x.locked);
public referralCode =
this.project(x => x.referralCode);
public referralEarned =
this.project(x => x.referralEarned);
public portalLink =
this.project(x => x.portalLink);
@ -100,6 +112,8 @@ export class TeamPlansState extends State<Snapshot> {
planOwner: payload.planOwner,
plans,
portalLink: payload.portalLink,
referralCode: payload.referralCode,
referralEarned: payload.referralEarned,
version,
}, 'Loading Success');
}),

4
frontend/src/app/shared/services/plans.service.spec.ts

@ -44,6 +44,8 @@ describe('PlansService', () => {
req.flush({
currentPlanId: '123',
portalLink: 'link/to/portal',
referralCode: 'CODE',
referralEarned: '100.00 EUR',
planOwner: '456',
plans: [
{
@ -84,6 +86,8 @@ describe('PlansService', () => {
payload: {
currentPlanId: '123',
portalLink: 'link/to/portal',
referralCode: 'CODE',
referralEarned: '100.00 EUR',
planOwner: '456',
plans: [
new PlanDto(

6
frontend/src/app/shared/services/shared.ts

@ -91,6 +91,12 @@ export type PlansPayload = Readonly<{
// The portal link if available.
portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed.
locked: PlanLockedReason;
}>;

14
frontend/src/app/shared/state/plans.state.ts

@ -33,6 +33,12 @@ interface Snapshot extends LoadingState {
// The portal link if available.
portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed.
locked?: PlanLockedReason;
@ -57,6 +63,12 @@ export class PlansState extends State<Snapshot> {
public locked =
this.project(x => x.locked);
public referralCode =
this.project(x => x.referralCode);
public referralEarned =
this.project(x => x.referralEarned);
public portalLink =
this.project(x => x.portalLink);
@ -105,6 +117,8 @@ export class PlansState extends State<Snapshot> {
planOwner: payload.planOwner,
plans,
portalLink: payload.portalLink,
referralCode: payload.referralCode,
referralEarned: payload.referralEarned,
version,
}, 'Loading Success');
}),

Loading…
Cancel
Save