Better profile caching.
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
|
public class WinoAccountAddOnCache
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
public bool HasAiPack { get; set; }
|
||||||
|
|
||||||
|
public int? AiUsageCount { get; set; }
|
||||||
|
|
||||||
|
public int? AiUsageLimit { get; set; }
|
||||||
|
|
||||||
|
public DateTime? AiBillingPeriodStartUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime? AiBillingPeriodEndUtc { get; set; }
|
||||||
|
|
||||||
|
public bool HasUnlimitedAccounts { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastUpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public interface IWinoAccountProfileService
|
|||||||
Task<ApiEnvelope<EmailConfirmationResendResultDto>> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default);
|
Task<ApiEnvelope<EmailConfirmationResendResultDto>> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default);
|
||||||
Task<ApiEnvelope<JsonElement>> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default);
|
Task<ApiEnvelope<JsonElement>> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default);
|
||||||
Task<WinoAccount?> GetActiveAccountAsync();
|
Task<WinoAccount?> GetActiveAccountAsync();
|
||||||
|
Task<WinoAccountAddOnSnapshot?> GetCachedAddOnSnapshotAsync();
|
||||||
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
|
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
|
||||||
Task<bool> HasActiveAccountAsync();
|
Task<bool> HasActiveAccountAsync();
|
||||||
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ namespace Wino.Core.Domain.Interfaces;
|
|||||||
|
|
||||||
public interface IWinoAddOnService
|
public interface IWinoAddOnService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
public sealed record WinoAccountAddOnSnapshot(
|
||||||
|
bool HasAiPack,
|
||||||
|
int? UsageCount = null,
|
||||||
|
int? UsageLimit = null,
|
||||||
|
DateTimeOffset? BillingPeriodStartUtc = null,
|
||||||
|
DateTimeOffset? BillingPeriodEndUtc = null,
|
||||||
|
bool HasUnlimitedAccounts = false,
|
||||||
|
DateTimeOffset? LastUpdatedUtc = null);
|
||||||
@@ -11,6 +11,7 @@ using Wino.Core.Domain.Entities.Shared;
|
|||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Personalization;
|
using Wino.Core.Domain.Models.Personalization;
|
||||||
using Wino.Core.Domain.Models.Settings;
|
using Wino.Core.Domain.Models.Settings;
|
||||||
using Wino.Core.Domain.Models.Translations;
|
using Wino.Core.Domain.Models.Translations;
|
||||||
@@ -493,10 +494,19 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
|||||||
|
|
||||||
private async Task LoadWinoAccountAsync()
|
private async Task LoadWinoAccountAsync()
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() => IsWinoAccountBusy = true);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
|
var cachedAddOns = await _profileService.GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (cachedAccount != null)
|
||||||
|
{
|
||||||
|
await ApplyWinoAccountStateAsync(cachedAccount.Email, cachedAccount.AccountStatus).ConfigureAwait(false);
|
||||||
|
UpdateAiPackState(cachedAddOns);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteUIThread(() => IsWinoAccountBusy = cachedAccount == null);
|
||||||
|
|
||||||
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
||||||
if (account == null)
|
if (account == null)
|
||||||
{
|
{
|
||||||
@@ -504,25 +514,23 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false);
|
|
||||||
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
|
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
|
||||||
|
var profileRefreshResult = await _profileService.RefreshProfileAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
var resolvedEmail = currentUserResponse.IsSuccess && currentUserResponse.Result != null
|
var resolvedAccount = profileRefreshResult.IsSuccess && profileRefreshResult.Account != null
|
||||||
? currentUserResponse.Result.Email
|
? profileRefreshResult.Account
|
||||||
: account.Email;
|
: account;
|
||||||
|
|
||||||
var resolvedStatus = currentUserResponse.IsSuccess && currentUserResponse.Result != null
|
await ApplyWinoAccountStateAsync(resolvedAccount.Email, resolvedAccount.AccountStatus).ConfigureAwait(false);
|
||||||
? currentUserResponse.Result.AccountStatus
|
|
||||||
: account.AccountStatus;
|
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
if (aiStatusResponse.IsSuccess)
|
||||||
{
|
{
|
||||||
IsWinoAccountSignedIn = true;
|
UpdateAiPackState(aiStatusResponse.Result);
|
||||||
WinoAccountEmail = resolvedEmail;
|
}
|
||||||
WinoAccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedStatus);
|
else
|
||||||
});
|
{
|
||||||
|
UpdateAiPackState(cachedAddOns);
|
||||||
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -534,6 +542,16 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyWinoAccountStateAsync(string email, string status)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
IsWinoAccountSignedIn = true;
|
||||||
|
WinoAccountEmail = email;
|
||||||
|
WinoAccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ResetWinoAccountStateAsync()
|
private async Task ResetWinoAccountStateAsync()
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
@@ -551,20 +569,45 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
|
|||||||
|
|
||||||
private void UpdateAiPackState(AiStatusResultDto aiStatus)
|
private void UpdateAiPackState(AiStatusResultDto aiStatus)
|
||||||
{
|
{
|
||||||
var hasAiPack = aiStatus?.HasAiPack == true;
|
UpdateAiPackState(
|
||||||
|
aiStatus?.HasAiPack == true,
|
||||||
|
aiStatus?.Used,
|
||||||
|
aiStatus?.MonthlyLimit,
|
||||||
|
aiStatus?.Remaining,
|
||||||
|
aiStatus?.CurrentPeriodStartUtc,
|
||||||
|
aiStatus?.CurrentPeriodEndUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAiPackState(WinoAccountAddOnSnapshot addOnSnapshot)
|
||||||
|
{
|
||||||
|
var remaining = addOnSnapshot?.HasAiPack == true && addOnSnapshot.UsageLimit is int limit && addOnSnapshot.UsageCount is int used
|
||||||
|
? limit - used
|
||||||
|
: (int?)null;
|
||||||
|
|
||||||
|
UpdateAiPackState(
|
||||||
|
addOnSnapshot?.HasAiPack == true,
|
||||||
|
addOnSnapshot?.UsageCount,
|
||||||
|
addOnSnapshot?.UsageLimit,
|
||||||
|
remaining,
|
||||||
|
addOnSnapshot?.BillingPeriodStartUtc,
|
||||||
|
addOnSnapshot?.BillingPeriodEndUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAiPackState(bool hasAiPack, int? used, int? limit, int? remaining, DateTimeOffset? periodStart, DateTimeOffset? periodEnd)
|
||||||
|
{
|
||||||
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
|
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
|
||||||
var billingText = string.Empty;
|
var billingText = string.Empty;
|
||||||
var usagePercent = 0d;
|
var usagePercent = 0d;
|
||||||
|
|
||||||
if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
|
if (hasAiPack && used is int usageCount && limit is int usageLimit && remaining is int remainingCount)
|
||||||
{
|
{
|
||||||
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
|
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, usageCount, usageLimit, remainingCount);
|
||||||
usagePercent = limit > 0 ? (double)used / limit * 100 : 0;
|
usagePercent = usageLimit > 0 ? (double)usageCount / usageLimit * 100 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
|
if (hasAiPack && periodStart is DateTimeOffset billingStart && periodEnd is DateTimeOffset billingEnd)
|
||||||
{
|
{
|
||||||
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
|
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, billingStart.LocalDateTime, billingEnd.LocalDateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ExecuteUIThread(() =>
|
_ = ExecuteUIThread(() =>
|
||||||
|
|||||||
@@ -296,21 +296,34 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() => IsBusy = true);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
|
var cachedAddOns = await _addOnService.GetAvailableAddOnsAsync(true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (cachedAccount != null)
|
||||||
|
{
|
||||||
|
await ApplyAccountStateAsync(cachedAccount).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedAddOns.Count > 0)
|
||||||
|
{
|
||||||
|
await UpdateAddOnsAsync(cachedAddOns).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteUIThread(() => IsBusy = cachedAccount == null && cachedAddOns.Count == 0);
|
||||||
|
|
||||||
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
||||||
|
var refreshedProfileResult = account == null
|
||||||
|
? null
|
||||||
|
: await _profileService.RefreshProfileAsync().ConfigureAwait(false);
|
||||||
var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
|
var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
var resolvedAccount = refreshedProfileResult?.IsSuccess == true && refreshedProfileResult.Account != null
|
||||||
{
|
? refreshedProfileResult.Account
|
||||||
IsSignedIn = account != null;
|
: account;
|
||||||
AccountEmail = account?.Email ?? string.Empty;
|
|
||||||
AccountStatusText = account == null
|
await ApplyAccountStateAsync(resolvedAccount).ConfigureAwait(false);
|
||||||
? string.Empty
|
|
||||||
: string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
|
|
||||||
});
|
|
||||||
|
|
||||||
await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
|
await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -327,6 +340,18 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAccountStateAsync(Wino.Core.Domain.Entities.Shared.WinoAccount? account)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
IsSignedIn = account != null;
|
||||||
|
AccountEmail = account?.Email ?? string.Empty;
|
||||||
|
AccountStatusText = account == null
|
||||||
|
? string.Empty
|
||||||
|
: string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleAddOnPurchasedAsync()
|
private async Task HandleAddOnPurchasedAsync()
|
||||||
{
|
{
|
||||||
await LoadAsync().ConfigureAwait(false);
|
await LoadAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ public class DatabaseService : IDatabaseService
|
|||||||
Connection.CreateTableAsync<CalendarAttachment>(),
|
Connection.CreateTableAsync<CalendarAttachment>(),
|
||||||
Connection.CreateTableAsync<Reminder>(),
|
Connection.CreateTableAsync<Reminder>(),
|
||||||
Connection.CreateTableAsync<MailInvitationCalendarMapping>(),
|
Connection.CreateTableAsync<MailInvitationCalendarMapping>(),
|
||||||
Connection.CreateTableAsync<WinoAccount>());
|
Connection.CreateTableAsync<WinoAccount>(),
|
||||||
|
Connection.CreateTableAsync<WinoAccountAddOnCache>());
|
||||||
|
|
||||||
await EnsureSchemaUpgradesAsync().ConfigureAwait(false);
|
await EnsureSchemaUpgradesAsync().ConfigureAwait(false);
|
||||||
await EnsureIndexesAsync().ConfigureAwait(false);
|
await EnsureIndexesAsync().ConfigureAwait(false);
|
||||||
@@ -207,6 +208,7 @@ SET {nameof(KeyboardShortcut.Action)} =
|
|||||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailAccountPreferences_AccountId ON MailAccountPreferences(AccountId)").ConfigureAwait(false);
|
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailAccountPreferences_AccountId ON MailAccountPreferences(AccountId)").ConfigureAwait(false);
|
||||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_CustomServerInformation_AccountId ON CustomServerInformation(AccountId)").ConfigureAwait(false);
|
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_CustomServerInformation_AccountId ON CustomServerInformation(AccountId)").ConfigureAwait(false);
|
||||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccount_Email ON WinoAccount(Email)").ConfigureAwait(false);
|
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccount_Email ON WinoAccount(Email)").ConfigureAwait(false);
|
||||||
|
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccountAddOnCache_AccountId ON WinoAccountAddOnCache(AccountId)").ConfigureAwait(false);
|
||||||
|
|
||||||
// Calendar indexes
|
// Calendar indexes
|
||||||
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false);
|
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IDatabaseService _databaseService;
|
private readonly IDatabaseService _databaseService;
|
||||||
|
private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1);
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
|
|
||||||
// private const string ApiUrl = "https://localhost:7204/";
|
// private const string ApiUrl = "https://localhost:7204/";
|
||||||
@@ -134,11 +135,12 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings").ConfigureAwait(false);
|
using var response = await SendAuthorizedAsync(
|
||||||
if (request == null)
|
() => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"),
|
||||||
return null;
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
if (response == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||||
return null;
|
return null;
|
||||||
@@ -158,13 +160,16 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, "api/v1/users/me/settings").ConfigureAwait(false);
|
using var response = await SendAuthorizedAsync(
|
||||||
if (request == null)
|
() => CreateAuthorizedRequestAsync(
|
||||||
|
HttpMethod.Put,
|
||||||
|
"api/v1/users/me/settings",
|
||||||
|
() => new StringContent(settingsJson, Encoding.UTF8, "application/json")),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
request.Content = new StringContent(settingsJson, Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -325,11 +330,12 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var request = await CreateAuthorizedRequestAsync(method, endpoint).ConfigureAwait(false);
|
using var response = await SendAuthorizedAsync(
|
||||||
if (request == null)
|
() => CreateAuthorizedRequestAsync(method, endpoint),
|
||||||
return ApiEnvelope<TResponse>.Failure("MissingAccessToken");
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
if (response == null)
|
||||||
|
return ApiEnvelope<TResponse>.Failure("MissingAccessToken");
|
||||||
|
|
||||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var envelope = string.IsNullOrWhiteSpace(payload)
|
var envelope = string.IsNullOrWhiteSpace(payload)
|
||||||
@@ -344,7 +350,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HttpRequestMessage?> CreateAuthorizedRequestAsync(HttpMethod method, string endpoint)
|
private async Task<HttpRequestMessage?> CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func<HttpContent>? contentFactory = null)
|
||||||
{
|
{
|
||||||
var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
|
var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
|
||||||
if (string.IsNullOrWhiteSpace(accessToken))
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
@@ -352,15 +358,98 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
|
|
||||||
var request = new HttpRequestMessage(method, endpoint);
|
var request = new HttpRequestMessage(method, endpoint);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
request.Content = contentFactory?.Invoke();
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage?> SendAuthorizedAsync(Func<Task<HttpRequestMessage?>> requestFactory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var initialRequest = await requestFactory().ConfigureAwait(false);
|
||||||
|
if (initialRequest == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(initialRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await TryRefreshAccessTokenAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Dispose();
|
||||||
|
|
||||||
|
using var retryRequest = await requestFactory().ConfigureAwait(false);
|
||||||
|
if (retryRequest == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _httpClient.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> GetAccessTokenAsync()
|
private async Task<string?> GetAccessTokenAsync()
|
||||||
{
|
{
|
||||||
var account = await _databaseService.Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
var account = await _databaseService.Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
||||||
return string.IsNullOrWhiteSpace(account?.AccessToken) ? null : account.AccessToken;
|
return string.IsNullOrWhiteSpace(account?.AccessToken) ? null : account.AccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryRefreshAccessTokenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var account = await _databaseService.Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
|
||||||
|
if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshResult = await RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!refreshResult.IsSuccess || refreshResult.Result == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshedAccount = MapAccount(refreshResult.Result, account.LastAuthenticatedUtc);
|
||||||
|
|
||||||
|
await _databaseService.Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
||||||
|
await _databaseService.Connection.InsertOrReplaceAsync(refreshedAccount, typeof(WinoAccount)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_tokenRefreshLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WinoAccount MapAccount(AuthResultDto result, DateTime lastAuthenticatedUtc)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = result.User.UserId,
|
||||||
|
Email = result.User.Email,
|
||||||
|
AccountStatus = result.User.AccountStatus,
|
||||||
|
HasPassword = result.User.HasPassword,
|
||||||
|
HasGoogleLogin = result.User.HasGoogleLogin,
|
||||||
|
HasFacebookLogin = result.User.HasFacebookLogin,
|
||||||
|
AccessToken = result.AccessToken,
|
||||||
|
AccessTokenExpiresAtUtc = result.AccessTokenExpiresAtUtc.UtcDateTime,
|
||||||
|
RefreshToken = result.RefreshToken,
|
||||||
|
RefreshTokenExpiresAtUtc = result.RefreshTokenExpiresAtUtc.UtcDateTime,
|
||||||
|
LastAuthenticatedUtc = lastAuthenticatedUtc == default ? DateTime.UtcNow : lastAuthenticatedUtc
|
||||||
|
};
|
||||||
|
|
||||||
private static bool ValidateCertificate(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, System.Net.Security.SslPolicyErrors sslPolicyErrors)
|
private static bool ValidateCertificate(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, System.Net.Security.SslPolicyErrors sslPolicyErrors)
|
||||||
{
|
{
|
||||||
if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true)
|
if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
@@ -377,6 +466,8 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
|||||||
{
|
{
|
||||||
_httpClient.Dispose();
|
_httpClient.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_tokenRefreshLock.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
private readonly IWinoAccountApiClient _apiClient;
|
private readonly IWinoAccountApiClient _apiClient;
|
||||||
private readonly IStoreManagementService _storeManagementService;
|
private readonly IStoreManagementService _storeManagementService;
|
||||||
private readonly SemaphoreSlim _billingCallbackLock = new(1, 1);
|
private readonly SemaphoreSlim _billingCallbackLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1);
|
||||||
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
|
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
|
||||||
|
|
||||||
public WinoAccountProfileService(IDatabaseService databaseService,
|
public WinoAccountProfileService(IDatabaseService databaseService,
|
||||||
@@ -67,29 +68,43 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
public async Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default)
|
public async Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken))
|
|
||||||
{
|
|
||||||
_logger.Warning("Wino account token refresh skipped because there is no active account or refresh token.");
|
|
||||||
return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.Information("Refreshing Wino account token for {Email}", account.Email);
|
try
|
||||||
var response = await _apiClient.RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false);
|
|
||||||
var result = await PersistResponseAsync(response).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
_logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode);
|
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
|
if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken))
|
||||||
|
{
|
||||||
|
_logger.Warning("Wino account token refresh skipped because there is no active account or refresh token.");
|
||||||
|
return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
|
||||||
|
{
|
||||||
|
return WinoAccountOperationResult.Success(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Information("Refreshing Wino account token for {Email}", account.Email);
|
||||||
|
var response = await _apiClient.RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false);
|
||||||
|
var result = await PersistResponseAsync(response).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
_logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Account != null && !AreEquivalentProfiles(account, result.Account))
|
||||||
|
{
|
||||||
|
PublishProfileUpdated(result.Account);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
if (result.Account != null && !AreEquivalentProfiles(account, result.Account))
|
|
||||||
{
|
{
|
||||||
PublishProfileUpdated(result.Account);
|
_tokenRefreshLock.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WinoAccountOperationResult> RefreshProfileAsync(CancellationToken cancellationToken = default)
|
public async Task<WinoAccountOperationResult> RefreshProfileAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -108,6 +123,15 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
}
|
}
|
||||||
|
|
||||||
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
||||||
|
var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts)
|
||||||
|
? cachedHasUnlimitedAccounts
|
||||||
|
: (bool?)null;
|
||||||
|
|
||||||
|
if (hasUnlimitedAccounts.HasValue)
|
||||||
|
{
|
||||||
|
await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (AreEquivalentProfiles(account, refreshedAccount))
|
if (AreEquivalentProfiles(account, refreshedAccount))
|
||||||
{
|
{
|
||||||
return WinoAccountOperationResult.Success(account);
|
return WinoAccountOperationResult.Success(account);
|
||||||
@@ -125,6 +149,18 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WinoAccountAddOnSnapshot?> GetCachedAddOnSnapshotAsync()
|
||||||
|
{
|
||||||
|
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
|
if (account == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = await GetAddOnCacheAsync(account.Id).ConfigureAwait(false);
|
||||||
|
return cache == null ? null : Map(cache);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default)
|
public async Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||||
@@ -179,6 +215,17 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger.Warning("Failed to load Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
_logger.Warning("Failed to load Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Result != null)
|
||||||
|
{
|
||||||
|
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
||||||
|
var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts)
|
||||||
|
? cachedHasUnlimitedAccounts
|
||||||
|
: (bool?)null;
|
||||||
|
|
||||||
|
await PersistProfileDataAsync(account, refreshedAccount, hasUnlimitedAccounts).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -196,6 +243,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
_logger.Warning("Failed to load AI status for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
_logger.Warning("Failed to load AI status for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Result != null)
|
||||||
|
{
|
||||||
|
await PersistAddOnCacheAsync(account.Id, response.Result).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -303,6 +356,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
await Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
||||||
|
await Connection.DeleteAllAsync<WinoAccountAddOnCache>().ConfigureAwait(false);
|
||||||
|
|
||||||
if (account != null)
|
if (account != null)
|
||||||
{
|
{
|
||||||
@@ -332,6 +386,20 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false);
|
await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount, bool? hasUnlimitedAccounts)
|
||||||
|
{
|
||||||
|
if (!AreEquivalentProfiles(originalAccount, refreshedAccount))
|
||||||
|
{
|
||||||
|
await PersistAccountAsync(refreshedAccount).ConfigureAwait(false);
|
||||||
|
PublishProfileUpdated(refreshedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnlimitedAccounts.HasValue)
|
||||||
|
{
|
||||||
|
await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void PublishProfileUpdated(WinoAccount account)
|
private void PublishProfileUpdated(WinoAccount account)
|
||||||
=> ReportUIChange(new WinoAccountProfileUpdatedMessage(account));
|
=> ReportUIChange(new WinoAccountProfileUpdatedMessage(account));
|
||||||
|
|
||||||
@@ -343,6 +411,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
|
|
||||||
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||||
|
if (cachedSnapshot?.HasUnlimitedAccounts == true)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
|
if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -356,12 +430,54 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
|||||||
var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
|
var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!response.IsSuccess || response.Result == null)
|
if (!response.IsSuccess || response.Result == null)
|
||||||
{
|
{
|
||||||
return false;
|
return (await GetCachedAddOnSnapshotAsync().ConfigureAwait(false))?.HasUnlimitedAccounts == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WinoAccountAddOnCache?> GetAddOnCacheAsync(Guid accountId)
|
||||||
|
=> await Connection.Table<WinoAccountAddOnCache>()
|
||||||
|
.Where(cache => cache.AccountId == accountId)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
private async Task PersistAddOnCacheAsync(Guid accountId, AiStatusResultDto? aiStatus = null, bool? hasUnlimitedAccounts = null)
|
||||||
|
{
|
||||||
|
var cache = await GetAddOnCacheAsync(accountId).ConfigureAwait(false) ?? new WinoAccountAddOnCache
|
||||||
|
{
|
||||||
|
AccountId = accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aiStatus != null)
|
||||||
|
{
|
||||||
|
cache.HasAiPack = aiStatus.HasAiPack;
|
||||||
|
cache.AiUsageCount = aiStatus.HasAiPack ? aiStatus.Used : null;
|
||||||
|
cache.AiUsageLimit = aiStatus.HasAiPack ? aiStatus.MonthlyLimit : null;
|
||||||
|
cache.AiBillingPeriodStartUtc = aiStatus.CurrentPeriodStartUtc?.UtcDateTime;
|
||||||
|
cache.AiBillingPeriodEndUtc = aiStatus.CurrentPeriodEndUtc?.UtcDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnlimitedAccounts.HasValue)
|
||||||
|
{
|
||||||
|
cache.HasUnlimitedAccounts = hasUnlimitedAccounts.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.LastUpdatedUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await Connection.InsertOrReplaceAsync(cache, typeof(WinoAccountAddOnCache)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WinoAccountAddOnSnapshot Map(WinoAccountAddOnCache cache)
|
||||||
|
=> new(
|
||||||
|
cache.HasAiPack,
|
||||||
|
cache.AiUsageCount,
|
||||||
|
cache.AiUsageLimit,
|
||||||
|
cache.AiBillingPeriodStartUtc is DateTime periodStartUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodStartUtc, DateTimeKind.Utc)) : null,
|
||||||
|
cache.AiBillingPeriodEndUtc is DateTime periodEndUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodEndUtc, DateTimeKind.Utc)) : null,
|
||||||
|
cache.HasUnlimitedAccounts,
|
||||||
|
new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc)));
|
||||||
|
|
||||||
private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right)
|
private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right)
|
||||||
=> left.Id == right.Id &&
|
=> left.Id == right.Id &&
|
||||||
string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&
|
string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ namespace Wino.Services;
|
|||||||
|
|
||||||
public sealed class WinoAddOnService : IWinoAddOnService
|
public sealed class WinoAddOnService : IWinoAddOnService
|
||||||
{
|
{
|
||||||
private static readonly WinoAddOnProductType[] AvailableAddOns =
|
|
||||||
[
|
|
||||||
WinoAddOnProductType.AI_PACK,
|
|
||||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly IWinoAccountProfileService _profileService;
|
private readonly IWinoAccountProfileService _profileService;
|
||||||
|
|
||||||
public WinoAddOnService(IWinoAccountProfileService profileService)
|
public WinoAddOnService(IWinoAccountProfileService profileService)
|
||||||
@@ -23,8 +17,15 @@ public sealed class WinoAddOnService : IWinoAddOnService
|
|||||||
_profileService = profileService;
|
_profileService = profileService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var cachedSnapshot = await _profileService.GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (useCachedDataOnly)
|
||||||
|
{
|
||||||
|
return BuildAddOnInfos(cachedSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
|
var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
|
||||||
var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
|
var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
|
||||||
|
|
||||||
@@ -32,6 +33,15 @@ public sealed class WinoAddOnService : IWinoAddOnService
|
|||||||
|
|
||||||
var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
|
var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
|
||||||
var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
|
var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
|
||||||
|
var hasUnlimitedAccounts = await hasUnlimitedAccountsTask.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (aiStatus == null && cachedSnapshot != null)
|
||||||
|
{
|
||||||
|
return BuildAddOnInfos(cachedSnapshot with
|
||||||
|
{
|
||||||
|
HasUnlimitedAccounts = hasUnlimitedAccounts || cachedSnapshot.HasUnlimitedAccounts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -46,7 +56,31 @@ public sealed class WinoAddOnService : IWinoAddOnService
|
|||||||
aiStatus?.CurrentPeriodEndUtc),
|
aiStatus?.CurrentPeriodEndUtc),
|
||||||
new WinoAddOnInfo(
|
new WinoAddOnInfo(
|
||||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||||
await hasUnlimitedAccountsTask.ConfigureAwait(false))
|
hasUnlimitedAccounts || cachedSnapshot?.HasUnlimitedAccounts == true)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<WinoAddOnInfo> BuildAddOnInfos(WinoAccountAddOnSnapshot? snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new WinoAddOnInfo(
|
||||||
|
WinoAddOnProductType.AI_PACK,
|
||||||
|
snapshot.HasAiPack,
|
||||||
|
snapshot.UsageCount,
|
||||||
|
snapshot.UsageLimit,
|
||||||
|
snapshot.HasAiPack && snapshot.UsageLimit is int limit && limit > 0 && snapshot.UsageCount is int used
|
||||||
|
? (double)used / limit * 100
|
||||||
|
: 0,
|
||||||
|
snapshot.BillingPeriodEndUtc),
|
||||||
|
new WinoAddOnInfo(
|
||||||
|
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||||
|
snapshot.HasUnlimitedAccounts)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user