using System.Text; using System.Text.Json; using DysonNetwork.Sphere.Connection; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Account; public class NotificationService( AppDatabase db, WebSocketService ws, ILogger logger, IHttpClientFactory httpFactory, IConfiguration config) { private readonly string _notifyTopic = config["Notifications:Topic"]!; private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); // TODO remove all push notification with this device id when this device is logged out public async Task SubscribePushNotification( Account account, NotificationPushProvider provider, string deviceId, string deviceToken ) { var existingSubscription = await db.NotificationPushSubscriptions .Where(s => s.AccountId == account.Id) .Where(s => (s.DeviceId == deviceId || s.DeviceToken == deviceToken) && s.Provider == provider) .FirstOrDefaultAsync(); if (existingSubscription is not null) { // Reset these audit fields to renew the lifecycle of this device token existingSubscription.DeviceId = deviceId; existingSubscription.DeviceToken = deviceToken; existingSubscription.UpdatedAt = SystemClock.Instance.GetCurrentInstant(); db.Update(existingSubscription); await db.SaveChangesAsync(); return existingSubscription; } var subscription = new NotificationPushSubscription { DeviceId = deviceId, DeviceToken = deviceToken, Provider = provider, AccountId = account.Id, }; db.NotificationPushSubscriptions.Add(subscription); await db.SaveChangesAsync(); return subscription; } public async Task SendNotification( Account account, string topic, string? title = null, string? subtitle = null, string? content = null, Dictionary? meta = null, bool isSilent = false ) { if (title is null && subtitle is null && content is null) { throw new ArgumentException("Unable to send notification that completely empty."); } var notification = new Notification { Topic = topic, Title = title, Subtitle = subtitle, Content = content, Meta = meta, AccountId = account.Id, }; db.Add(notification); await db.SaveChangesAsync(); if (!isSilent) _ = DeliveryNotification(notification); return notification; } public async Task DeliveryNotification(Notification notification) { ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket { Type = "notifications.new", Data = notification }); // Pushing the notification var subscribers = await db.NotificationPushSubscriptions .Where(s => s.AccountId == notification.AccountId) .ToListAsync(); await _PushNotification(notification, subscribers); } public async Task MarkNotificationsViewed(ICollection notifications) { var now = SystemClock.Instance.GetCurrentInstant(); var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList(); if (id.Count == 0) return; await db.Notifications .Where(n => id.Contains(n.Id)) .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now) ); } public async Task BroadcastNotification(Notification notification, bool save = false) { if (save) { var accounts = await db.Accounts.ToListAsync(); var notifications = accounts.Select(x => { var newNotification = new Notification { Topic = notification.Topic, Title = notification.Title, Subtitle = notification.Subtitle, Content = notification.Content, Meta = notification.Meta, Priority = notification.Priority, Account = x, AccountId = x.Id }; return newNotification; }).ToList(); await db.BulkInsertAsync(notifications); } var subscribers = await db.NotificationPushSubscriptions .ToListAsync(); await _PushNotification(notification, subscribers); } public async Task SendNotificationBatch(Notification notification, List accounts, bool save = false) { if (save) { var notifications = accounts.Select(x => { var newNotification = new Notification { Topic = notification.Topic, Title = notification.Title, Subtitle = notification.Subtitle, Content = notification.Content, Meta = notification.Meta, Priority = notification.Priority, Account = x, AccountId = x.Id }; return newNotification; }).ToList(); await db.BulkInsertAsync(notifications); } var accountsId = accounts.Select(x => x.Id).ToList(); var subscribers = await db.NotificationPushSubscriptions .Where(s => accountsId.Contains(s.AccountId)) .ToListAsync(); await _PushNotification(notification, subscribers); } private List> _BuildNotificationPayload(Notification notification, IEnumerable subscriptions) { var subDict = subscriptions .GroupBy(x => x.Provider) .ToDictionary(x => x.Key, x => x.ToList()); var notifications = subDict.Select(value => { int platformCode = value.Key switch { NotificationPushProvider.Apple => 1, NotificationPushProvider.Google => 2, _ => throw new InvalidOperationException($"Unknown push provider: {value.Key}") }; var tokens = value.Value.Select(x => x.DeviceToken).ToList(); return _BuildNotificationPayload(notification, platformCode, tokens); }).ToList(); return notifications.ToList(); } private Dictionary _BuildNotificationPayload(Notification notification, int platformCode, IEnumerable deviceTokens) { var alertDict = new Dictionary(); var dict = new Dictionary { ["notif_id"] = notification.Id.ToString(), ["apns_id"] = notification.Id.ToString(), ["topic"] = _notifyTopic, ["category"] = notification.Topic, ["tokens"] = deviceTokens, ["alert"] = new Dictionary(), ["data"] = new Dictionary { ["d_topic"] = notification.Topic, ["meta"] = notification.Meta ?? new Dictionary(), }, ["mutable_content"] = true, ["priority"] = notification.Priority >= 5 ? "high" : "normal", }; if (!string.IsNullOrWhiteSpace(notification.Title)) { dict["title"] = notification.Title; alertDict["title"] = notification.Title; } if (!string.IsNullOrWhiteSpace(notification.Content)) { dict["message"] = notification.Content; alertDict["body"] = notification.Content; } if (!string.IsNullOrWhiteSpace(notification.Subtitle)) { dict["message"] = $"{notification.Subtitle}\n{dict["message"]}"; alertDict["subtitle"] = notification.Subtitle; } if (notification.Priority >= 5) dict["sound"] = "default"; dict["platform"] = platformCode; dict["alert"] = alertDict; return dict; } private async Task _PushNotification(Notification notification, IEnumerable subscriptions) { var subList = subscriptions.ToList(); if (subList.Count == 0) return; var requestDict = new Dictionary { ["notifications"] = _BuildNotificationPayload(notification, subList) }; var client = httpFactory.CreateClient(); client.BaseAddress = _notifyEndpoint; var request = await client.PostAsync("/api/push", new StringContent( JsonSerializer.Serialize(requestDict), Encoding.UTF8, "application/json" )); request.EnsureSuccessStatusCode(); } }