diff --git a/DysonNetwork.Pass/Account/Account.cs b/DysonNetwork.Pass/Account/Account.cs index c4f605a..1f24fcd 100644 --- a/DysonNetwork.Pass/Account/Account.cs +++ b/DysonNetwork.Pass/Account/Account.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -31,6 +32,8 @@ public class Account : ModelBase [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); + + [NotMapped] public SubscriptionReferenceObject? PerkSubscription { get; set; } public Shared.Proto.Account ToProtoValue() { diff --git a/DysonNetwork.Pass/Account/AccountController.cs b/DysonNetwork.Pass/Account/AccountController.cs index ee2054b..3300978 100644 --- a/DysonNetwork.Pass/Account/AccountController.cs +++ b/DysonNetwork.Pass/Account/AccountController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Wallet; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -12,6 +13,7 @@ public class AccountController( AppDatabase db, AuthService auth, AccountService accounts, + SubscriptionService subscriptions, AccountEventService events ) : ControllerBase { @@ -25,7 +27,12 @@ public class AccountController( .Include(e => e.Profile) .Where(a => a.Name == name) .FirstOrDefaultAsync(); - return account is null ? new NotFoundResult() : account; + if (account is null) return new NotFoundResult(); + + var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); + account.PerkSubscription = perk?.ToReference(); + + return account; } [HttpGet("{name}/badges")] diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs index ea5f57d..53a6810 100644 --- a/DysonNetwork.Pass/Account/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Permission; +using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; @@ -18,6 +19,7 @@ namespace DysonNetwork.Pass.Account; public class AccountCurrentController( AppDatabase db, AccountService accounts, + SubscriptionService subscriptions, AccountEventService events, AuthService auth, FileService.FileServiceClient files, @@ -36,6 +38,9 @@ public class AccountCurrentController( .Include(e => e.Profile) .Where(e => e.Id == userId) .FirstOrDefaultAsync(); + + var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); + account.PerkSubscription = perk?.ToReference(); return Ok(account); } diff --git a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs index e0d6173..8abda2e 100644 --- a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs +++ b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs @@ -1,15 +1,16 @@ +using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Proto; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.EntityFrameworkCore; using NodaTime; -using NodaTime.Serialization.Protobuf; namespace DysonNetwork.Pass.Account; public class AccountServiceGrpc( AppDatabase db, RelationshipService relationships, + SubscriptionService subscriptions, IClock clock, ILogger logger ) @@ -34,6 +35,9 @@ public class AccountServiceGrpc( if (account == null) throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); + var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); + account.PerkSubscription = perk?.ToReference(); + return account.ToProtoValue(); } @@ -51,13 +55,21 @@ public class AccountServiceGrpc( .Where(a => accountIds.Contains(a.Id)) .Include(a => a.Profile) .ToListAsync(); + + var perks = await subscriptions.GetPerkSubscriptionsAsync( + accounts.Select(x => x.Id).ToList() + ); + foreach (var account in accounts) + if (perks.TryGetValue(account.Id, out var perk)) + account.PerkSubscription = perk?.ToReference(); var response = new GetAccountBatchResponse(); response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); return response; } - public override async Task LookupAccountBatch(LookupAccountBatchRequest request, ServerCallContext context) + public override async Task LookupAccountBatch(LookupAccountBatchRequest request, + ServerCallContext context) { var accountNames = request.Names.ToList(); var accounts = await _db.Accounts @@ -65,6 +77,14 @@ public class AccountServiceGrpc( .Where(a => accountNames.Contains(a.Name)) .Include(a => a.Profile) .ToListAsync(); + + var perks = await subscriptions.GetPerkSubscriptionsAsync( + accounts.Select(x => x.Id).ToList() + ); + foreach (var account in accounts) + if (perks.TryGetValue(account.Id, out var perk)) + account.PerkSubscription = perk?.ToReference(); + var response = new GetAccountBatchResponse(); response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); return response; @@ -101,6 +121,13 @@ public class AccountServiceGrpc( .Include(a => a.Profile) .ToListAsync(); + var perks = await subscriptions.GetPerkSubscriptionsAsync( + accounts.Select(x => x.Id).ToList() + ); + foreach (var account in accounts) + if (perks.TryGetValue(account.Id, out var perk)) + account.PerkSubscription = perk?.ToReference(); + var response = new ListAccountsResponse { TotalSize = totalCount, diff --git a/DysonNetwork.Pass/Wallet/Subscription.cs b/DysonNetwork.Pass/Wallet/Subscription.cs index dd37c5c..19e1974 100644 --- a/DysonNetwork.Pass/Wallet/Subscription.cs +++ b/DysonNetwork.Pass/Wallet/Subscription.cs @@ -176,6 +176,57 @@ public class Subscription : ModelBase return BasePrice; } } + + /// + /// Returns a reference object that contains a subset of subscription data + /// suitable for client-side use, with sensitive information removed. + /// + public SubscriptionReferenceObject ToReference() + { + return new SubscriptionReferenceObject + { + Id = Id, + Identifier = Identifier, + BegunAt = BegunAt, + EndedAt = EndedAt, + IsActive = IsActive, + IsAvailable = IsAvailable, + IsFreeTrial = IsFreeTrial, + Status = Status, + BasePrice = BasePrice, + FinalPrice = FinalPrice, + RenewalAt = RenewalAt, + AccountId = AccountId + }; + } +} + +/// +/// A reference object for Subscription that contains only non-sensitive information +/// suitable for client-side use. +/// +public class SubscriptionReferenceObject : ModelBase +{ + public Guid Id { get; set; } + public string Identifier { get; set; } = null!; + public Instant BegunAt { get; set; } + public Instant? EndedAt { get; set; } + public bool IsActive { get; set; } + public bool IsAvailable { get; set; } + public bool IsFreeTrial { get; set; } + public SubscriptionStatus Status { get; set; } + public decimal BasePrice { get; set; } + public decimal FinalPrice { get; set; } + public Instant? RenewalAt { get; set; } + public Guid AccountId { get; set; } + + /// + /// Gets the human-readable name of the subscription type if available. + /// + [NotMapped] + public string? DisplayName => SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name) + ? name + : null; } public class PaymentDetails diff --git a/DysonNetwork.Pass/Wallet/SubscriptionService.cs b/DysonNetwork.Pass/Wallet/SubscriptionService.cs index bd71d7b..27ccbfa 100644 --- a/DysonNetwork.Pass/Wallet/SubscriptionService.cs +++ b/DysonNetwork.Pass/Wallet/SubscriptionService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Wallet.PaymentHandlers; @@ -377,7 +379,10 @@ public class SubscriptionService( public async Task GetSubscriptionAsync(Guid accountId, params string[] identifiers) { // Create a unique cache key for this subscription - var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}"; + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))); + var hashIdentifier = Convert.ToHexStringLower(hashBytes); + + var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{hashIdentifier}"; // Try to get the subscription from cache first var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); @@ -398,4 +403,71 @@ public class SubscriptionService( return subscription; } + + private const string SubscriptionPerkCacheKeyPrefix = "subscription:perk:"; + + private static readonly List PerkIdentifiers = + [SubscriptionType.Stellar, SubscriptionType.Nova, SubscriptionType.Supernova]; + + public async Task GetPerkSubscriptionAsync(Guid accountId) + { + var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}"; + + // Try to get the subscription from cache first + var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); + if (found && cachedSubscription != null) + { + return cachedSubscription; + } + + // If not in cache, get from database + var subscription = await db.WalletSubscriptions + .Where(s => s.AccountId == accountId && PerkIdentifiers.Contains(s.Identifier)) + .OrderByDescending(s => s.BegunAt) + .FirstOrDefaultAsync(); + + // Cache the result if found (with 30 minutes expiry) + if (subscription != null) + await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30)); + + return subscription; + } + + + public async Task> GetPerkSubscriptionsAsync(List accountIds) + { + var result = new Dictionary(); + var missingAccountIds = new List(); + + // Try to get the subscription from cache first + foreach (var accountId in accountIds) + { + var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}"; + var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); + if (found && cachedSubscription != null) + result[accountId] = cachedSubscription; + else + missingAccountIds.Add(accountId); + } + + if (missingAccountIds.Count <= 0) return result; + + // If not in cache, get from database + var subscriptions = await db.WalletSubscriptions + .Where(s => missingAccountIds.Contains(s.AccountId)) + .Where(s => PerkIdentifiers.Contains(s.Identifier)) + .ToListAsync(); + + // Group the subscriptions by account id + foreach (var subscription in subscriptions) + { + result[subscription.AccountId] = subscription; + + // Cache the result if found (with 30 minutes expiry) + var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}"; + await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30)); + } + + return result; + } } \ No newline at end of file