using System.Globalization; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; using Microsoft.EntityFrameworkCore; using NodaTime; using NodaTime.Serialization.Protobuf; using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole; using PublisherType = DysonNetwork.Shared.Models.PublisherType; namespace DysonNetwork.Sphere.Publisher; public class PublisherService( AppDatabase db, FileReferenceService.FileReferenceServiceClient fileRefs, SocialCreditService.SocialCreditServiceClient socialCredits, ExperienceService.ExperienceServiceClient experiences, ICacheService cache, RemoteAccountService remoteAccounts ) { public async Task GetPublisherByName(string name) { return await db.Publishers .Where(e => e.Name == name) .FirstOrDefaultAsync(); } private const string UserPublishersCacheKey = "accounts:{0}:publishers"; public async Task> GetUserPublishers(Guid userId) { var cacheKey = string.Format(UserPublishersCacheKey, userId); // Try to get publishers from the cache first var publishers = await cache.GetAsync>(cacheKey); if (publishers is not null) return publishers; // If not in cache, fetch from a database var publishersId = await db.PublisherMembers .Where(p => p.AccountId == userId) .Select(p => p.PublisherId) .ToListAsync(); publishers = await db.Publishers .Where(p => publishersId.Contains(p.Id)) .ToListAsync(); // Store in a cache for 5 minutes await cache.SetAsync(cacheKey, publishers, TimeSpan.FromMinutes(5)); return publishers; } public async Task>> GetUserPublishersBatch(List userIds) { var result = new Dictionary>(); var missingIds = new List(); // Try to get publishers from cache for each user foreach (var userId in userIds) { var cacheKey = string.Format(UserPublishersCacheKey, userId); var publishers = await cache.GetAsync>(cacheKey); if (publishers != null) result[userId] = publishers; else missingIds.Add(userId); } if (missingIds.Count <= 0) return result; { // Fetch missing data from database var publisherMembers = await db.PublisherMembers .Where(p => missingIds.Contains(p.AccountId)) .Select(p => new { p.AccountId, p.PublisherId }) .ToListAsync(); var publisherIds = publisherMembers.Select(p => p.PublisherId).Distinct().ToList(); var publishers = await db.Publishers .Where(p => publisherIds.Contains(p.Id)) .ToListAsync(); // Group publishers by user id foreach (var userId in missingIds) { var userPublisherIds = publisherMembers .Where(p => p.AccountId == userId) .Select(p => p.PublisherId) .ToList(); var userPublishers = publishers .Where(p => userPublisherIds.Contains(p.Id)) .ToList(); result[userId] = userPublishers; // Cache individual results var cacheKey = string.Format(UserPublishersCacheKey, userId); await cache.SetAsync(cacheKey, userPublishers, TimeSpan.FromMinutes(5)); } } return result; } public const string SubscribedPublishersCacheKey = "accounts:{0}:subscribed-publishers"; public async Task> GetSubscribedPublishers(Guid userId) { var cacheKey = string.Format(SubscribedPublishersCacheKey, userId); // Try to get publishers from the cache first var publishers = await cache.GetAsync>(cacheKey); if (publishers is not null) return publishers; // If not in cache, fetch from a database var publishersId = await db.PublisherSubscriptions .Where(p => p.AccountId == userId) .Where(p => p.Status == PublisherSubscriptionStatus.Active) .Select(p => p.PublisherId) .ToListAsync(); publishers = await db.Publishers .Where(p => publishersId.Contains(p.Id)) .ToListAsync(); // Store in a cache for 5 minutes await cache.SetAsync(cacheKey, publishers, TimeSpan.FromMinutes(5)); return publishers; } private const string PublisherMembersCacheKey = "publishers:{0}:members"; public async Task> GetPublisherMembers(Guid publisherId) { var cacheKey = string.Format(PublisherMembersCacheKey, publisherId); // Try to get members from the cache first var members = await cache.GetAsync>(cacheKey); if (members is not null) return members; // If not in cache, fetch from a database members = await db.PublisherMembers .Where(p => p.PublisherId == publisherId) .ToListAsync(); // Store in cache for 5 minutes (consistent with other cache durations in the class) await cache.SetAsync(cacheKey, members, TimeSpan.FromMinutes(5)); return members; } public async Task CreateIndividualPublisher( Account account, string? name, string? nick, string? bio, SnCloudFileReferenceObject? picture, SnCloudFileReferenceObject? background ) { var publisher = new SnPublisher { Type = PublisherType.Individual, Name = name ?? account.Name, Nick = nick ?? account.Nick, Bio = bio ?? account.Profile.Bio, Picture = picture ?? (account.Profile.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(account.Profile.Picture)), Background = background ?? (account.Profile.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(account.Profile.Background)), AccountId = Guid.Parse(account.Id), Members = [ new() { AccountId = Guid.Parse(account.Id), Role = PublisherMemberRole.Owner, JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) } ] }; db.Publishers.Add(publisher); await db.SaveChangesAsync(); if (publisher.Picture is not null) { await fileRefs.CreateReferenceAsync( new CreateReferenceRequest { FileId = publisher.Picture.Id, Usage = "publisher.picture", ResourceId = publisher.ResourceIdentifier, } ); } if (publisher.Background is not null) { await fileRefs.CreateReferenceAsync( new CreateReferenceRequest { FileId = publisher.Background.Id, Usage = "publisher.background", ResourceId = publisher.ResourceIdentifier, } ); } return publisher; } public async Task CreateOrganizationPublisher( SnRealm realm, Account account, string? name, string? nick, string? bio, SnCloudFileReferenceObject? picture, SnCloudFileReferenceObject? background ) { var publisher = new SnPublisher { Type = PublisherType.Organizational, Name = name ?? realm.Slug, Nick = nick ?? realm.Name, Bio = bio ?? realm.Description, Picture = picture ?? SnCloudFileReferenceObject.FromProtoValue(account.Profile.Picture), Background = background ?? SnCloudFileReferenceObject.FromProtoValue(account.Profile.Background), RealmId = realm.Id, Members = new List { new() { AccountId = Guid.Parse(account.Id), Role = PublisherMemberRole.Owner, JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) } } }; db.Publishers.Add(publisher); await db.SaveChangesAsync(); if (publisher.Picture is not null) { await fileRefs.CreateReferenceAsync( new CreateReferenceRequest { FileId = publisher.Picture.Id, Usage = "publisher.picture", ResourceId = publisher.ResourceIdentifier, } ); } if (publisher.Background is not null) { await fileRefs.CreateReferenceAsync( new CreateReferenceRequest { FileId = publisher.Background.Id, Usage = "publisher.background", ResourceId = publisher.ResourceIdentifier, } ); } return publisher; } public class PublisherStats { public int PostsCreated { get; set; } public int StickerPacksCreated { get; set; } public int StickersCreated { get; set; } public int UpvoteReceived { get; set; } public int DownvoteReceived { get; set; } public int SubscribersCount { get; set; } } private const string PublisherStatsCacheKey = "publisher:{0}:stats"; private const string PublisherHeatmapCacheKey = "publisher:{0}:heatmap"; private const string PublisherFeatureCacheKey = "publisher:{0}:feature:{1}"; public async Task GetPublisherStats(string name) { var cacheKey = string.Format(PublisherStatsCacheKey, name); var stats = await cache.GetAsync(cacheKey); if (stats is not null) return stats; var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name); if (publisher is null) return null; var postsCount = await db.Posts.Where(e => e.Publisher.Id == publisher.Id).CountAsync(); var postsUpvotes = await db.PostReactions .Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Positive) .CountAsync(); var postsDownvotes = await db.PostReactions .Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Negative) .CountAsync(); var stickerPacksId = await db.StickerPacks.Where(e => e.Publisher.Id == publisher.Id).Select(e => e.Id) .ToListAsync(); var stickerPacksCount = stickerPacksId.Count; var stickersCount = await db.Stickers.Where(e => stickerPacksId.Contains(e.PackId)).CountAsync(); var subscribersCount = await db.PublisherSubscriptions.Where(e => e.PublisherId == publisher.Id).CountAsync(); stats = new PublisherStats { PostsCreated = postsCount, StickerPacksCreated = stickerPacksCount, StickersCreated = stickersCount, UpvoteReceived = postsUpvotes, DownvoteReceived = postsDownvotes, SubscribersCount = subscribersCount, }; await cache.SetAsync(cacheKey, stats, TimeSpan.FromMinutes(5)); return stats; } public async Task GetPublisherHeatmap(string name) { var cacheKey = string.Format(PublisherHeatmapCacheKey, name); var heatmap = await cache.GetAsync(cacheKey); if (heatmap is not null) return heatmap; var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name); if (publisher is null) return null; var now = SystemClock.Instance.GetCurrentInstant(); var periodStart = now.Minus(Duration.FromDays(365)); var periodEnd = now; var postGroups = await db.Posts .Where(p => p.PublisherId == publisher.Id && p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd) .Select(p => p.CreatedAt.InUtc().Date) .GroupBy(d => d) .Select(g => new { Date = g.Key, Count = g.Count() }) .ToListAsync(); var items = postGroups.Select(p => new ActivityHeatmapItem { Date = p.Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(), Count = p.Count }).ToList(); heatmap = new ActivityHeatmap { Unit = "posts", PeriodStart = periodStart, PeriodEnd = periodEnd, Items = items.OrderBy(i => i.Date).ToList() }; await cache.SetAsync(cacheKey, heatmap, TimeSpan.FromMinutes(5)); return heatmap; } public async Task SetFeatureFlag(Guid publisherId, string flag) { var featureFlag = await db.PublisherFeatures .FirstOrDefaultAsync(f => f.PublisherId == publisherId && f.Flag == flag); if (featureFlag == null) { featureFlag = new SnPublisherFeature { PublisherId = publisherId, Flag = flag, }; db.PublisherFeatures.Add(featureFlag); } else { featureFlag.ExpiredAt = SystemClock.Instance.GetCurrentInstant(); } await db.SaveChangesAsync(); await cache.RemoveAsync(string.Format(PublisherFeatureCacheKey, publisherId, flag)); } public async Task HasFeature(Guid publisherId, string flag) { var cacheKey = string.Format(PublisherFeatureCacheKey, publisherId, flag); var isEnabled = await cache.GetAsync(cacheKey); if (isEnabled.HasValue) return isEnabled.Value; var now = SystemClock.Instance.GetCurrentInstant(); var featureFlag = await db.PublisherFeatures .FirstOrDefaultAsync(f => f.PublisherId == publisherId && f.Flag == flag && (f.ExpiredAt == null || f.ExpiredAt > now) ); isEnabled = featureFlag is not null; await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5)); return isEnabled.Value; } public async Task IsMemberWithRole(Guid publisherId, Guid accountId, Shared.Models.PublisherMemberRole requiredRole) { var member = await db.Publishers .Where(p => p.Id == publisherId) .SelectMany(p => p.Members) .FirstOrDefaultAsync(m => m.AccountId == accountId); return member != null && member.Role >= requiredRole; } public async Task LoadMemberAccount(SnPublisherMember member) { var account = await remoteAccounts.GetAccount(member.AccountId); member.Account = SnAccount.FromProtoValue(account); return member; } public async Task> LoadMemberAccounts(ICollection members) { var accountIds = members.Select(m => m.AccountId).ToList(); var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); return [ .. members.Select(m => { if (accounts.TryGetValue(m.AccountId, out var account)) m.Account = SnAccount.FromProtoValue(account); return m; }) ]; } public async Task> LoadIndividualPublisherAccounts(ICollection publishers) { var accountIds = publishers .Where(p => p.AccountId.HasValue && p.Type == PublisherType.Individual) .Select(p => p.AccountId!.Value) .ToList(); if (accountIds.Count == 0) return publishers.ToList(); var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); foreach (var p in publishers) { if (p.AccountId.HasValue && accounts.TryGetValue(p.AccountId.Value, out var account)) p.Account = SnAccount.FromProtoValue(account); } return publishers.ToList(); } public class PublisherRewardPreview { public int Experience { get; set; } public int SocialCredits { get; set; } } public async Task GetPublisherExpectedReward(Guid publisherId) { var cacheKey = $"publisher:{publisherId}:rewards"; var (found, cached) = await cache.GetAsyncWithStatus(cacheKey); if (found) return cached!; var now = SystemClock.Instance.GetCurrentInstant(); var yesterday = now.InZone(DateTimeZone.Utc).Date.PlusDays(-1); var periodStart = yesterday.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); var periodEnd = periodStart.Plus(Duration.FromDays(1)).Minus(Duration.FromMilliseconds(1)); // Get posts stats for this publisher: count, id, exclude content var postsInPeriod = await db.Posts .Where(p => p.PublisherId == publisherId && p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd) .Select(p => new { Id = p.Id, AwardedScore = p.AwardedScore }) .ToListAsync(); // Get reactions for these posts var postIds = postsInPeriod.Select(p => p.Id).ToList(); var reactions = await db.PostReactions .Where(r => postIds.Contains(r.PostId)) .ToListAsync(); if (postsInPeriod.Count == 0) return new PublisherRewardPreview { Experience = 0, SocialCredits = 0 }; // Calculate stats var postCount = postsInPeriod.Count; var upvotes = reactions.Count(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive); var downvotes = reactions.Count(r => r.Attitude == Shared.Models.PostReactionAttitude.Negative); var awardScore = postsInPeriod.Sum(p => (double)p.AwardedScore); // Each post counts as 100 experiences, // and each point (upvote - downvote + award score * 0.1) count as 10 experiences var netVotes = upvotes - downvotes; var points = netVotes + awardScore * 0.1; var experienceFromPosts = postCount * 100; var experienceFromPoints = (int)(points * 10); var totalExperience = experienceFromPosts + experienceFromPoints; var preview = new PublisherRewardPreview { Experience = totalExperience, SocialCredits = (int)(points * 10) }; await cache.SetAsync(cacheKey, preview, TimeSpan.FromMinutes(5)); return preview; } public async Task SettlePublisherRewards() { var now = SystemClock.Instance.GetCurrentInstant(); var yesterday = now.InZone(DateTimeZone.Utc).Date.PlusDays(-1); var periodStart = yesterday.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); var periodEnd = periodStart.Plus(Duration.FromDays(1)).Minus(Duration.FromMilliseconds(1)); // Get posts stats: count, publisher id, exclude content var postsInPeriod = await db.Posts .Where(p => p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd) .Select(p => new { Id = p.Id, PublisherId = p.PublisherId, AwardedScore = p.AwardedScore }) .ToListAsync(); // Get reactions for these posts var postIds = postsInPeriod.Select(p => p.Id).ToList(); var reactions = await db.PostReactions .Where(r => postIds.Contains(r.PostId)) .ToListAsync(); // Group stats by publisher id var postIdToPublisher = postsInPeriod.ToDictionary(p => p.Id, p => p.PublisherId); var publisherStats = postsInPeriod .GroupBy(p => p.PublisherId) .ToDictionary(g => g.Key, g => new { PostCount = g.Count(), Upvotes = 0, Downvotes = 0, AwardScore = g.Sum(p => (double)p.AwardedScore) }); foreach (var reaction in reactions.Where(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive)) { if (!postIdToPublisher.TryGetValue(reaction.PostId, out var pubId) || !publisherStats.TryGetValue(pubId, out var stat)) continue; stat = new { stat.PostCount, Upvotes = stat.Upvotes + 1, stat.Downvotes, stat.AwardScore }; publisherStats[pubId] = stat; } foreach (var reaction in reactions.Where(r => r.Attitude == Shared.Models.PostReactionAttitude.Negative)) { if (!postIdToPublisher.TryGetValue(reaction.PostId, out var pubId) || !publisherStats.TryGetValue(pubId, out var stat)) continue; stat = new { stat.PostCount, stat.Upvotes, Downvotes = stat.Downvotes + 1, stat.AwardScore }; publisherStats[pubId] = stat; } var date = now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var publisherIds = publisherStats.Keys.ToList(); var publishers = await db.Publishers .Where(p => publisherIds.Contains(p.Id)) .Select(p => new { p.Id, p.Name }) .ToDictionaryAsync(p => p.Id, p => p); var publisherMembers = await db.PublisherMembers .Where(m => publisherIds.Contains(m.PublisherId)) .ToListAsync(); var accountIds = publisherMembers.Select(m => m.AccountId).ToList(); var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var publisherAccounts = publisherMembers .GroupBy(m => m.PublisherId) .ToDictionary(g => g.Key, g => g.Select(m => SnAccount.FromProtoValue(accounts[m.AccountId])).ToList()); // Foreach loop through publishers to calculate experience foreach (var (publisherId, value) in publisherStats) { var postCount = value.PostCount; var upvotes = value.Upvotes; var downvotes = value.Downvotes; var awardScore = value.AwardScore; // Fetch or calculate here // Each post counts as 100 experiences, // and each point (upvote - downvote + award score * 0.1) count as 10 experiences var netVotes = upvotes - downvotes; var points = netVotes + awardScore * 0.1; var experienceFromPosts = postCount * 100; var experienceFromPoints = (int)(points * 10); var totalExperience = experienceFromPosts + experienceFromPoints; if (!publisherAccounts.TryGetValue(publisherId, out var receivers) || receivers.Count == 0) continue; var publisherName = publishers.TryGetValue(publisherId, out var pub) ? pub.Name : "unknown"; // Use totalExperience for rewarding foreach (var receiver in receivers) { await experiences.AddRecordAsync(new AddExperienceRecordRequest { Reason = $"Publishing Reward on {date} for @{publisherName}", ReasonType = "publishers.rewards", AccountId = receiver.Id.ToString(), Delta = totalExperience, }); } } // Foreach loop through publishers to set social credit var expiredAt = now.InZone(DateTimeZone.Utc).Date.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc) .Minus(Duration.FromMilliseconds(1)).ToInstant(); foreach (var (publisherId, value) in publisherStats) { var upvotes = value.Upvotes; var downvotes = value.Downvotes; var awardScore = value.AwardScore; // Fetch or calculate here var netVotes = upvotes - downvotes; var points = netVotes + awardScore * 0.1; var socialCreditDelta = (int)(points * 10); if (socialCreditDelta == 0) continue; if (!publisherAccounts.TryGetValue(publisherId, out var receivers) || receivers.Count == 0) continue; var publisherName = publishers.TryGetValue(publisherId, out var pub) ? pub.Name : "unknown"; // Set social credit for receivers, expired before next settle foreach (var receiver in receivers) { await socialCredits.AddRecordAsync(new AddSocialCreditRecordRequest { Reason = $"Publishing Reward on {date} for @{publisherName}", ReasonType = "publishers.rewards", AccountId = receiver.Id.ToString(), Delta = socialCreditDelta, ExpiredAt = expiredAt.ToTimestamp(), }); } } } }