diff --git a/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs b/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs new file mode 100644 index 00000000..aae4a63e --- /dev/null +++ b/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs @@ -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; } +} diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index 4f5e049d..6dfa914f 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -22,6 +22,7 @@ public interface IWinoAccountProfileService Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default); Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default); Task GetActiveAccountAsync(); + Task GetCachedAddOnSnapshotAsync(); Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task HasActiveAccountAsync(); Task HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); diff --git a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs index b2e0fc6c..9d5b36eb 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs @@ -8,5 +8,5 @@ namespace Wino.Core.Domain.Interfaces; public interface IWinoAddOnService { - Task> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default); + Task> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs new file mode 100644 index 00000000..55affe1f --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs @@ -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); diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index 3fe47410..8d15aad2 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -11,6 +11,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Personalization; using Wino.Core.Domain.Models.Settings; using Wino.Core.Domain.Models.Translations; @@ -493,10 +494,19 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, private async Task LoadWinoAccountAsync() { - await ExecuteUIThread(() => IsWinoAccountBusy = true); - 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); if (account == null) { @@ -504,25 +514,23 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, return; } - var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false); var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false); + var profileRefreshResult = await _profileService.RefreshProfileAsync().ConfigureAwait(false); - var resolvedEmail = currentUserResponse.IsSuccess && currentUserResponse.Result != null - ? currentUserResponse.Result.Email - : account.Email; + var resolvedAccount = profileRefreshResult.IsSuccess && profileRefreshResult.Account != null + ? profileRefreshResult.Account + : account; - var resolvedStatus = currentUserResponse.IsSuccess && currentUserResponse.Result != null - ? currentUserResponse.Result.AccountStatus - : account.AccountStatus; + await ApplyWinoAccountStateAsync(resolvedAccount.Email, resolvedAccount.AccountStatus).ConfigureAwait(false); - await ExecuteUIThread(() => + if (aiStatusResponse.IsSuccess) { - IsWinoAccountSignedIn = true; - WinoAccountEmail = resolvedEmail; - WinoAccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedStatus); - }); - - UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null); + UpdateAiPackState(aiStatusResponse.Result); + } + else + { + UpdateAiPackState(cachedAddOns); + } } 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() { await ExecuteUIThread(() => @@ -551,20 +569,45 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, 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 billingText = string.Empty; 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); - usagePercent = limit > 0 ? (double)used / limit * 100 : 0; + usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, usageCount, usageLimit, remainingCount); + 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(() => diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 61a3508b..516fb3fa 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -296,21 +296,34 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, private async Task LoadAsync() { - await ExecuteUIThread(() => IsBusy = true); - 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 refreshedProfileResult = account == null + ? null + : await _profileService.RefreshProfileAsync().ConfigureAwait(false); var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false); - await ExecuteUIThread(() => - { - IsSignedIn = account != null; - AccountEmail = account?.Email ?? string.Empty; - AccountStatusText = account == null - ? string.Empty - : string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus); - }); + var resolvedAccount = refreshedProfileResult?.IsSuccess == true && refreshedProfileResult.Account != null + ? refreshedProfileResult.Account + : account; + + await ApplyAccountStateAsync(resolvedAccount).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() { await LoadAsync().ConfigureAwait(false); diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index 6e69c962..42bae844 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -68,7 +68,8 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), - Connection.CreateTableAsync()); + Connection.CreateTableAsync(), + Connection.CreateTableAsync()); await EnsureSchemaUpgradesAsync().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_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_WinoAccountAddOnCache_AccountId ON WinoAccountAddOnCache(AccountId)").ConfigureAwait(false); // Calendar indexes await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false); diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs index beda906f..bc47a846 100644 --- a/Wino.Services/WinoAccountApiClient.cs +++ b/Wino.Services/WinoAccountApiClient.cs @@ -25,6 +25,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { private readonly HttpClient _httpClient; private readonly IDatabaseService _databaseService; + private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1); private readonly bool _ownsHttpClient; // private const string ApiUrl = "https://localhost:7204/"; @@ -134,11 +135,12 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { try { - using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings").ConfigureAwait(false); - if (request == null) - return null; + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"), + 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) return null; @@ -158,13 +160,16 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { try { - using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, "api/v1/users/me/settings").ConfigureAwait(false); - if (request == null) + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync( + HttpMethod.Put, + "api/v1/users/me/settings", + () => new StringContent(settingsJson, Encoding.UTF8, "application/json")), + cancellationToken).ConfigureAwait(false); + + if (response == null) return false; - request.Content = new StringContent(settingsJson, Encoding.UTF8, "application/json"); - - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch @@ -325,11 +330,12 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { try { - using var request = await CreateAuthorizedRequestAsync(method, endpoint).ConfigureAwait(false); - if (request == null) - return ApiEnvelope.Failure("MissingAccessToken"); + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync(method, endpoint), + cancellationToken).ConfigureAwait(false); - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response == null) + return ApiEnvelope.Failure("MissingAccessToken"); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var envelope = string.IsNullOrWhiteSpace(payload) @@ -344,7 +350,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable } } - private async Task CreateAuthorizedRequestAsync(HttpMethod method, string endpoint) + private async Task CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func? contentFactory = null) { var accessToken = await GetAccessTokenAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(accessToken)) @@ -352,15 +358,98 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable var request = new HttpRequestMessage(method, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Content = contentFactory?.Invoke(); return request; } + private async Task SendAuthorizedAsync(Func> 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 GetAccessTokenAsync() { var account = await _databaseService.Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); return string.IsNullOrWhiteSpace(account?.AccessToken) ? null : account.AccessToken; } + private async Task TryRefreshAccessTokenAsync(CancellationToken cancellationToken) + { + await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var account = await _databaseService.Connection.Table().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().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) { if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true) @@ -377,6 +466,8 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { _httpClient.Dispose(); } + + _tokenRefreshLock.Dispose(); } } diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index 86da903c..efda1927 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -22,6 +22,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun private readonly IWinoAccountApiClient _apiClient; private readonly IStoreManagementService _storeManagementService; private readonly SemaphoreSlim _billingCallbackLock = new(1, 1); + private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1); private readonly ILogger _logger = Log.ForContext(); public WinoAccountProfileService(IDatabaseService databaseService, @@ -67,29 +68,43 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun public async Task RefreshAsync(CancellationToken cancellationToken = default) { - 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); - } + await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); - _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) + try { - _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; } - - if (result.Account != null && !AreEquivalentProfiles(account, result.Account)) + finally { - PublishProfileUpdated(result.Account); + _tokenRefreshLock.Release(); } - - return result; } public async Task RefreshProfileAsync(CancellationToken cancellationToken = default) @@ -108,6 +123,15 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun } 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)) { return WinoAccountOperationResult.Success(account); @@ -125,6 +149,18 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return account; } + public async Task 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 GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); @@ -179,6 +215,17 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (!response.IsSuccess) { _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; @@ -196,6 +243,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (!response.IsSuccess) { _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; @@ -303,6 +356,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun } await Connection.DeleteAllAsync().ConfigureAwait(false); + await Connection.DeleteAllAsync().ConfigureAwait(false); if (account != null) { @@ -332,6 +386,20 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun 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) => ReportUIChange(new WinoAccountProfileUpdatedMessage(account)); @@ -343,6 +411,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun private async Task HasUnlimitedAccountsAsync(CancellationToken cancellationToken) { + var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false); + if (cachedSnapshot?.HasUnlimitedAccounts == true) + { + return true; + } + if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false)) { return true; @@ -356,12 +430,54 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess || response.Result == null) { - return false; + return (await GetCachedAddOnSnapshotAsync().ConfigureAwait(false))?.HasUnlimitedAccounts == true; } return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts; } + private async Task GetAddOnCacheAsync(Guid accountId) + => await Connection.Table() + .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) => left.Id == right.Id && string.Equals(left.Email, right.Email, StringComparison.Ordinal) && diff --git a/Wino.Services/WinoAddOnService.cs b/Wino.Services/WinoAddOnService.cs index dd069453..47dcf100 100644 --- a/Wino.Services/WinoAddOnService.cs +++ b/Wino.Services/WinoAddOnService.cs @@ -10,12 +10,6 @@ namespace Wino.Services; public sealed class WinoAddOnService : IWinoAddOnService { - private static readonly WinoAddOnProductType[] AvailableAddOns = - [ - WinoAddOnProductType.AI_PACK, - WinoAddOnProductType.UNLIMITED_ACCOUNTS - ]; - private readonly IWinoAccountProfileService _profileService; public WinoAddOnService(IWinoAccountProfileService profileService) @@ -23,8 +17,15 @@ public sealed class WinoAddOnService : IWinoAddOnService _profileService = profileService; } - public async Task> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default) + public async Task> 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 hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken); @@ -32,6 +33,15 @@ public sealed class WinoAddOnService : IWinoAddOnService var aiStatusResponse = await aiStatusTask.ConfigureAwait(false); 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 [ @@ -46,7 +56,31 @@ public sealed class WinoAddOnService : IWinoAddOnService aiStatus?.CurrentPeriodEndUtc), new WinoAddOnInfo( WinoAddOnProductType.UNLIMITED_ACCOUNTS, - await hasUnlimitedAccountsTask.ConfigureAwait(false)) + hasUnlimitedAccounts || cachedSnapshot?.HasUnlimitedAccounts == true) + ]; + } + + private static IReadOnlyList 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) ]; } }