using CorePush.Apple; using CorePush.Firebase; using DysonNetwork.Pusher.Connection; using DysonNetwork.Shared.Proto; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Pusher.Notification; public class PushService { private readonly AppDatabase _db; private readonly WebSocketService _ws; private readonly ILogger _logger; private readonly FirebaseSender? _fcm; private readonly ApnSender? _apns; private readonly string? _apnsTopic; public PushService( IConfiguration config, AppDatabase db, WebSocketService ws, IHttpClientFactory httpFactory, ILogger logger ) { var cfgSection = config.GetSection("Notifications:Push"); // Set up Firebase Cloud Messaging var fcmConfig = cfgSection.GetValue("Google"); if (fcmConfig != null && File.Exists(fcmConfig)) _fcm = new FirebaseSender(File.ReadAllText(fcmConfig), httpFactory.CreateClient()); // Set up Apple Push Notification Service var apnsKeyPath = cfgSection.GetValue("Apple:PrivateKey"); if (apnsKeyPath != null && File.Exists(apnsKeyPath)) { _apns = new ApnSender(new ApnSettings { P8PrivateKey = File.ReadAllText(apnsKeyPath), P8PrivateKeyId = cfgSection.GetValue("Apple:PrivateKeyId"), TeamId = cfgSection.GetValue("Apple:TeamId"), AppBundleIdentifier = cfgSection.GetValue("Apple:BundleIdentifier"), ServerType = cfgSection.GetValue("Production") ? ApnServerType.Production : ApnServerType.Development }, httpFactory.CreateClient()); _apnsTopic = cfgSection.GetValue("Apple:BundleIdentifier"); } _db = db; _ws = ws; _logger = logger; } public async Task UnsubscribeDevice(string deviceId) { await _db.PushSubscriptions .Where(s => s.DeviceId == deviceId) .ExecuteDeleteAsync(); } public async Task SubscribeDevice( string deviceId, string deviceToken, PushProvider provider, Account account ) { var now = SystemClock.Instance.GetCurrentInstant(); var accountId = Guid.Parse(account.Id!); // Check for existing subscription with same device ID or token var existingSubscription = await _db.PushSubscriptions .Where(s => s.AccountId == accountId) .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) .FirstOrDefaultAsync(); if (existingSubscription != null) { // Update existing subscription existingSubscription.DeviceId = deviceId; existingSubscription.DeviceToken = deviceToken; existingSubscription.Provider = provider; existingSubscription.UpdatedAt = now; _db.Update(existingSubscription); await _db.SaveChangesAsync(); return existingSubscription; } // Create new subscription var subscription = new PushSubscription { DeviceId = deviceId, DeviceToken = deviceToken, Provider = provider, AccountId = accountId, CreatedAt = now, UpdatedAt = now }; _db.PushSubscriptions.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, string? actionUri = null, bool isSilent = false, bool save = true) { if (title is null && subtitle is null && content is null) throw new ArgumentException("Unable to send notification that completely empty."); if (actionUri is not null) meta["action_uri"] = actionUri; var accountId = Guid.Parse(account.Id!); var notification = new Notification { Topic = topic, Title = title, Subtitle = subtitle, Content = content, Meta = meta, AccountId = accountId, }; if (save) { _db.Add(notification); await _db.SaveChangesAsync(); } if (!isSilent) _ = DeliveryNotification(notification); } public async Task DeliveryNotification(Notification notification) { // Pushing the notification var subscribers = await _db.PushSubscriptions .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 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, AccountId = x }; return newNotification; }).ToList(); await _db.BulkInsertAsync(notifications); } var subscribers = await _db.PushSubscriptions .Where(s => accounts.Contains(s.AccountId)) .ToListAsync(); await _PushNotification(notification, subscribers); } private async Task _PushNotification( Notification notification, IEnumerable subscriptions ) { var tasks = subscriptions .Select(subscription => _PushSingleNotification(notification, subscription)) .ToList(); await Task.WhenAll(tasks); } private async Task _PushSingleNotification(Notification notification, PushSubscription subscription) { try { _logger.LogDebug( $"Pushing notification {notification.Topic} #{notification.Id} to device #{subscription.DeviceId}"); switch (subscription.Provider) { case PushProvider.Google: if (_fcm == null) throw new InvalidOperationException("Firebase Cloud Messaging is not initialized."); var body = string.Empty; if (!string.IsNullOrEmpty(notification.Subtitle) || !string.IsNullOrEmpty(notification.Content)) { body = string.Join("\n", notification.Subtitle ?? string.Empty, notification.Content ?? string.Empty).Trim(); } await _fcm.SendAsync(new Dictionary { ["message"] = new Dictionary { ["token"] = subscription.DeviceToken, ["notification"] = new Dictionary { ["title"] = notification.Title ?? string.Empty, ["body"] = body }, ["data"] = new Dictionary { ["id"] = notification.Id, ["topic"] = notification.Topic, ["meta"] = notification.Meta ?? new Dictionary() } } }); break; case PushProvider.Apple: if (_apns == null) throw new InvalidOperationException("Apple Push Notification Service is not initialized."); var alertDict = new Dictionary(); if (!string.IsNullOrEmpty(notification.Title)) alertDict["title"] = notification.Title; if (!string.IsNullOrEmpty(notification.Subtitle)) alertDict["subtitle"] = notification.Subtitle; if (!string.IsNullOrEmpty(notification.Content)) alertDict["body"] = notification.Content; var payload = new Dictionary { ["topic"] = _apnsTopic, ["aps"] = new Dictionary { ["alert"] = alertDict, ["sound"] = notification.Priority >= 5 ? "default" : null, ["mutable-content"] = 1 }, ["meta"] = notification.Meta ?? new Dictionary() }; await _apns.SendAsync( payload, deviceToken: subscription.DeviceToken, apnsId: notification.Id.ToString(), apnsPriority: notification.Priority, apnPushType: ApnPushType.Alert ); break; default: throw new InvalidOperationException($"Push provider not supported: {subscription.Provider}"); } } catch (Exception ex) { _logger.LogError(ex, $"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}"); throw new Exception($"Failed to send notification to {subscription.Provider}: {ex.Message}", ex); } _logger.LogInformation( $"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId}"); } }