using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Localization;
namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(
AppDatabase db,
Post.PostService ps,
ILocalizationService localizer,
ICacheService cache,
RingService.RingServiceClient pusher,
AccountService.AccountServiceClient accounts
)
{
///
/// Checks if a subscription exists between the account and publisher
///
/// The account ID
/// The publisher ID
/// True if a subscription exists, false otherwise
public async Task SubscriptionExistsAsync(Guid accountId, Guid publisherId)
{
return await db.PublisherSubscriptions
.AnyAsync(p => p.AccountId == accountId &&
p.PublisherId == publisherId);
}
///
/// Gets a subscription by account and publisher ID
///
/// The account ID
/// The publisher ID
/// The subscription or null if not found
public async Task GetSubscriptionAsync(Guid accountId, Guid publisherId)
{
return await db.PublisherSubscriptions
.Include(p => p.Publisher)
.FirstOrDefaultAsync(p => p.AccountId == accountId && p.PublisherId == publisherId);
}
///
/// Notifies all subscribers about a new post from a publisher
///
/// The new post
/// The number of subscribers notified
public async Task NotifySubscriberPost(SnPost post)
{
if (!post.PublisherId.HasValue || post.Publisher is null)
return 0;
if (post.RepliedPostId is not null)
return 0;
if (post.Visibility != Shared.Models.PostVisibility.Public)
return 0;
// Create notification data
var (title, message) = ps.ChopPostForNotification(post);
// Data to include with the notification
var data = new Dictionary
{
["post_id"] = post.Id,
["publisher_id"] = post.PublisherId.Value.ToString()
};
if (post.Attachments.Any(p => p.MimeType?.StartsWith("image/") ?? false))
data["image"] =
post.Attachments
.Where(p => p.MimeType?.StartsWith("image/") ?? false)
.Select(p => p.Id).First();
if (post.Publisher?.Picture is not null) data["pfp"] = post.Publisher.Picture.Id;
// Gather subscribers
var subscribers = await db.PublisherSubscriptions
.Where(p => p.PublisherId == post.PublisherId)
.ToListAsync();
if (subscribers.Count == 0)
return 0;
List categorySubscribers = [];
if (post.Categories.Count > 0)
{
var categoryIds = post.Categories.Select(x => x.Id).ToList();
var subs = await db.PostCategorySubscriptions
.Where(s => s.CategoryId != null && categoryIds.Contains(s.CategoryId.Value))
.ToListAsync();
categorySubscribers.AddRange(subs);
}
if (post.Tags.Count > 0)
{
var tagIds = post.Tags.Select(x => x.Id).ToList();
var subs = await db.PostCategorySubscriptions
.Where(s => s.TagId != null && tagIds.Contains(s.TagId.Value))
.ToListAsync();
categorySubscribers.AddRange(subs);
}
List requestAccountIds = [];
requestAccountIds.AddRange(subscribers.Select(x => x.AccountId.ToString()));
requestAccountIds.AddRange(categorySubscribers.Select(x => x.AccountId.ToString()));
var queryRequest = new GetAccountBatchRequest();
queryRequest.Id.AddRange(requestAccountIds.Distinct());
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
// Notify each subscriber
var notifiedCount = 0;
foreach (var target in queryResponse.Accounts.GroupBy(x => x.Language))
{
try
{
CultureService.SetCultureInfo(target.Key);
var notification = new PushNotification
{
Topic = "posts.new",
Title = localizer.Get("postSubscriptionTitle", args: new { publisherNick = post.Publisher!.Nick, title }),
Body = message,
Meta = GrpcTypeHelper.ConvertObjectToByteString(data),
IsSavable = true,
ActionUri = $"/posts/{post.Id}"
};
var request = new SendPushNotificationToUsersRequest { Notification = notification };
request.UserIds.AddRange(target.Select(x => x.Id.ToString()));
await pusher.SendPushNotificationToUsersAsync(request);
notifiedCount++;
}
catch (Exception)
{
// Log the error but continue with other notifications
// We don't want one failed notification to stop the others
}
}
return notifiedCount;
}
///
/// Gets all active subscriptions for an account
///
/// The account ID
/// A list of active subscriptions
public async Task> GetAccountSubscriptionsAsync(Guid accountId)
{
return await db.PublisherSubscriptions
.Include(p => p.Publisher)
.Where(p => p.AccountId == accountId)
.ToListAsync();
}
///
/// Gets all active subscribers for a publisher
///
/// The publisher ID
/// A list of active subscriptions
public async Task> GetPublisherSubscribersAsync(Guid publisherId)
{
return await db.PublisherSubscriptions
.Where(p => p.PublisherId == publisherId)
.ToListAsync();
}
///
/// Creates a new subscription between an account and a publisher
///
/// The account ID
/// The publisher ID
/// The created subscription
public async Task CreateSubscriptionAsync(
Guid accountId,
Guid publisherId
)
{
// Check if a subscription already exists
var existingSubscription = await GetSubscriptionAsync(accountId, publisherId);
if (existingSubscription != null)
return existingSubscription;
// Create a new subscription
var subscription = new SnPublisherSubscription
{
AccountId = accountId,
PublisherId = publisherId,
};
db.PublisherSubscriptions.Add(subscription);
await db.SaveChangesAsync();
await cache.RemoveAsync(string.Format(PublisherService.SubscribedPublishersCacheKey, accountId));
return subscription;
}
///
/// Deletes a subscription
///
/// The account ID
/// The publisher ID
/// True if the subscription was deleted, false if it wasn't found
public async Task CancelSubscriptionAsync(Guid accountId, Guid publisherId)
{
var subscription = await GetSubscriptionAsync(accountId, publisherId);
if (subscription is null)
return false;
db.PublisherSubscriptions.Remove(subscription);
await db.SaveChangesAsync();
await cache.RemoveAsync(string.Format(PublisherService.SubscribedPublishersCacheKey, accountId));
return true;
}
}