💥 Rename Pusher to Ring
This commit is contained in:
		
							
								
								
									
										21
									
								
								DysonNetwork.Ring/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Ring/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public class Notification : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string Topic { get; set; } = null!; | ||||
|     [MaxLength(1024)] public string? Title { get; set; } | ||||
|     [MaxLength(2048)] public string? Subtitle { get; set; } | ||||
|     [MaxLength(4096)] public string? Content { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object?> Meta { get; set; } = new(); | ||||
|     public int Priority { get; set; } = 10; | ||||
|     public Instant? ViewedAt { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
| } | ||||
|  | ||||
							
								
								
									
										163
									
								
								DysonNetwork.Ring/Notification/NotificationController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								DysonNetwork.Ring/Notification/NotificationController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/notifications")] | ||||
| public class NotificationController( | ||||
|     AppDatabase db, | ||||
|     PushService nty | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet("count")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<int>> CountUnreadNotifications() | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var count = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId && s.ViewedAt == null) | ||||
|             .CountAsync(); | ||||
|         return Ok(count); | ||||
|     } | ||||
|  | ||||
|     [HttpGet] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<Notification>>> ListNotifications( | ||||
|         [FromQuery] int offset = 0, | ||||
|         // The page size set to 5 is to avoid the client pulled the notification | ||||
|         // but didn't render it in the screen-viewable region. | ||||
|         [FromQuery] int take = 8 | ||||
|     ) | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var totalCount = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId) | ||||
|             .CountAsync(); | ||||
|         var notifications = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId) | ||||
|             .OrderByDescending(e => e.CreatedAt) | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         Response.Headers["X-Total"] = totalCount.ToString(); | ||||
|         await nty.MarkNotificationsViewed(notifications.ToList()); | ||||
|  | ||||
|         return Ok(notifications); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("all/read")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult> MarkAllNotificationsViewed() | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         await nty.MarkAllNotificationsViewed(accountId); | ||||
|         return Ok(); | ||||
|     } | ||||
|  | ||||
|     public class PushNotificationSubscribeRequest | ||||
|     { | ||||
|         [MaxLength(4096)] public string DeviceToken { get; set; } = null!; | ||||
|         public PushProvider Provider { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpPut("subscription")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<PushSubscription>> | ||||
|         SubscribeToPushNotification( | ||||
|             [FromBody] PushNotificationSubscribeRequest request | ||||
|         ) | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         var currentUser = currentUserValue as Account; | ||||
|         if (currentUser == null) return Unauthorized(); | ||||
|         var currentSession = currentSessionValue as AuthSession; | ||||
|         if (currentSession == null) return Unauthorized(); | ||||
|  | ||||
|         var result = | ||||
|             await nty.SubscribeDevice( | ||||
|                 currentSession.Challenge.DeviceId, | ||||
|                 request.DeviceToken, | ||||
|                 request.Provider, | ||||
|                 currentUser | ||||
|             ); | ||||
|  | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("subscription")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<int>> UnsubscribeFromPushNotification() | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         var currentUser = currentUserValue as Account; | ||||
|         if (currentUser == null) return Unauthorized(); | ||||
|         var currentSession = currentSessionValue as AuthSession; | ||||
|         if (currentSession == null) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var affectedRows = await db.PushSubscriptions | ||||
|             .Where(s => | ||||
|                 s.AccountId == accountId && | ||||
|                 s.DeviceId == currentSession.Challenge.DeviceId | ||||
|             ).ExecuteDeleteAsync(); | ||||
|         return Ok(affectedRows); | ||||
|     } | ||||
|  | ||||
|     public class NotificationRequest | ||||
|     { | ||||
|         [Required][MaxLength(1024)] public string Topic { get; set; } = null!; | ||||
|         [Required][MaxLength(1024)] public string Title { get; set; } = null!; | ||||
|         [MaxLength(2048)] public string? Subtitle { get; set; } | ||||
|         [Required][MaxLength(4096)] public string Content { get; set; } = null!; | ||||
|         public Dictionary<string, object?>? Meta { get; set; } | ||||
|         public int Priority { get; set; } = 10; | ||||
|     } | ||||
|  | ||||
|     public class NotificationWithAimRequest : NotificationRequest | ||||
|     { | ||||
|         [Required] public List<Guid> AccountId { get; set; } = null!; | ||||
|     } | ||||
|  | ||||
|     [HttpPost("send")] | ||||
|     [Authorize] | ||||
|     [RequiredPermission("global", "notifications.send")] | ||||
|     public async Task<ActionResult> SendNotification( | ||||
|         [FromBody] NotificationWithAimRequest request, | ||||
|         [FromQuery] bool save = false | ||||
|     ) | ||||
|     { | ||||
|         await nty.SendNotificationBatch( | ||||
|             new Notification | ||||
|             { | ||||
|                 CreatedAt = SystemClock.Instance.GetCurrentInstant(), | ||||
|                 UpdatedAt = SystemClock.Instance.GetCurrentInstant(), | ||||
|                 Topic = request.Topic, | ||||
|                 Title = request.Title, | ||||
|                 Subtitle = request.Subtitle, | ||||
|                 Content = request.Content, | ||||
|                 Meta = request.Meta ?? [], | ||||
|             }, | ||||
|             request.AccountId, | ||||
|             save | ||||
|         ); | ||||
|         return Ok(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								DysonNetwork.Ring/Notification/NotificationFlushHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Ring/Notification/NotificationFlushHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using EFCore.BulkExtensions; | ||||
| using NodaTime; | ||||
| using Quartz; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public class NotificationFlushHandler(AppDatabase db) : IFlushHandler<Notification> | ||||
| { | ||||
|     public async Task FlushAsync(IReadOnlyList<Notification> items) | ||||
|     { | ||||
|         await db.BulkInsertAsync(items.Select(x => | ||||
|         { | ||||
|             x.CreatedAt = SystemClock.Instance.GetCurrentInstant(); | ||||
|             x.UpdatedAt = x.CreatedAt; | ||||
|             return x; | ||||
|         }), config => config.ConflictOption = ConflictOption.Ignore); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class NotificationFlushJob(FlushBufferService fbs, NotificationFlushHandler hdl) : IJob | ||||
| { | ||||
|     public async Task Execute(IJobExecutionContext context) | ||||
|     { | ||||
|         await fbs.FlushAsync(hdl); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										385
									
								
								DysonNetwork.Ring/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										385
									
								
								DysonNetwork.Ring/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,385 @@ | ||||
| using CorePush.Apple; | ||||
| using CorePush.Firebase; | ||||
| using DysonNetwork.Ring.Connection; | ||||
| using DysonNetwork.Ring.Services; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using WebSocketPacket = DysonNetwork.Ring.Connection.WebSocketPacket; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public class PushService | ||||
| { | ||||
|     private readonly AppDatabase _db; | ||||
|     private readonly WebSocketService _ws; | ||||
|     private readonly QueueService _queueService; | ||||
|     private readonly ILogger<PushService> _logger; | ||||
|     private readonly FirebaseSender? _fcm; | ||||
|     private readonly ApnSender? _apns; | ||||
|     private readonly string? _apnsTopic; | ||||
|  | ||||
|     public PushService( | ||||
|         IConfiguration config, | ||||
|         AppDatabase db, | ||||
|         WebSocketService ws, | ||||
|         QueueService queueService, | ||||
|         IHttpClientFactory httpFactory, | ||||
|         ILogger<PushService> logger | ||||
|     ) | ||||
|     { | ||||
|         var cfgSection = config.GetSection("Notifications:Push"); | ||||
|  | ||||
|         // Set up Firebase Cloud Messaging | ||||
|         var fcmConfig = cfgSection.GetValue<string>("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<string>("Apple:PrivateKey"); | ||||
|         if (apnsKeyPath != null && File.Exists(apnsKeyPath)) | ||||
|         { | ||||
|             _apns = new ApnSender(new ApnSettings | ||||
|             { | ||||
|                 P8PrivateKey = File.ReadAllText(apnsKeyPath), | ||||
|                 P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"), | ||||
|                 TeamId = cfgSection.GetValue<string>("Apple:TeamId"), | ||||
|                 AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"), | ||||
|                 ServerType = cfgSection.GetValue<bool>("Production") | ||||
|                     ? ApnServerType.Production | ||||
|                     : ApnServerType.Development | ||||
|             }, httpFactory.CreateClient()); | ||||
|             _apnsTopic = cfgSection.GetValue<string>("Apple:BundleIdentifier"); | ||||
|         } | ||||
|  | ||||
|         _db = db; | ||||
|         _ws = ws; | ||||
|         _queueService = queueService; | ||||
|         _logger = logger; | ||||
|     } | ||||
|  | ||||
|     public async Task UnsubscribeDevice(string deviceId) | ||||
|     { | ||||
|         await _db.PushSubscriptions | ||||
|             .Where(s => s.DeviceId == deviceId) | ||||
|             .ExecuteDeleteAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<PushSubscription> 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) | ||||
|         { | ||||
|             existingSubscription.DeviceId = deviceId; | ||||
|             existingSubscription.DeviceToken = deviceToken; | ||||
|             existingSubscription.Provider = provider; | ||||
|             existingSubscription.UpdatedAt = now; | ||||
|  | ||||
|             _db.Update(existingSubscription); | ||||
|             await _db.SaveChangesAsync(); | ||||
|             return existingSubscription; | ||||
|         } | ||||
|  | ||||
|         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<string, object?>? meta = null, | ||||
|         string? actionUri = null, | ||||
|         bool isSilent = false, | ||||
|         bool save = true) | ||||
|     { | ||||
|         meta ??= []; | ||||
|         if (title is null && subtitle is null && content is null) | ||||
|             throw new ArgumentException("Unable to send notification that is 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.Notifications.Add(notification); | ||||
|             await _db.SaveChangesAsync(); | ||||
|         } | ||||
|  | ||||
|         if (!isSilent) | ||||
|             _ = _queueService.EnqueuePushNotification(notification, accountId, save); | ||||
|     } | ||||
|  | ||||
|     public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         _ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket() | ||||
|         { | ||||
|             Type = "notifications.new", | ||||
|             Data = notification, | ||||
|         }); | ||||
|          | ||||
|         try | ||||
|         { | ||||
|             _logger.LogInformation( | ||||
|                 "Delivering push notification: {NotificationTopic} with meta {NotificationMeta}", | ||||
|                 notification.Topic, | ||||
|                 notification.Meta | ||||
|             ); | ||||
|  | ||||
|             // Get all push subscriptions for the account | ||||
|             var subscriptions = await _db.PushSubscriptions | ||||
|                 .Where(s => s.AccountId == notification.AccountId) | ||||
|                 .ToListAsync(cancellationToken); | ||||
|  | ||||
|             if (subscriptions.Count == 0) | ||||
|             { | ||||
|                 _logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Send push notifications | ||||
|             var tasks = new List<Task>(); | ||||
|             foreach (var subscription in subscriptions) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     tasks.Add(SendPushNotificationAsync(subscription, notification)); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, "Error sending push notification to {DeviceId}", subscription.DeviceId); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             await Task.WhenAll(tasks); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "Error in DeliverPushNotification"); | ||||
|             throw; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task MarkNotificationsViewed(ICollection<Notification> 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 MarkAllNotificationsViewed(Guid accountId) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         await _db.Notifications | ||||
|             .Where(n => n.AccountId == accountId) | ||||
|             .Where(n => n.ViewedAt == null) | ||||
|             .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)); | ||||
|     } | ||||
|  | ||||
|     public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false) | ||||
|     { | ||||
|         if (save) | ||||
|         { | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|             var notifications = accounts.Select(accountId => new Notification | ||||
|             { | ||||
|                 Topic = notification.Topic, | ||||
|                 Title = notification.Title, | ||||
|                 Subtitle = notification.Subtitle, | ||||
|                 Content = notification.Content, | ||||
|                 Meta = notification.Meta, | ||||
|                 Priority = notification.Priority, | ||||
|                 AccountId = accountId, | ||||
|                 CreatedAt = now, | ||||
|                 UpdatedAt = now | ||||
|             }).ToList(); | ||||
|  | ||||
|             if (notifications.Count != 0) | ||||
|             { | ||||
|                 await _db.Notifications.AddRangeAsync(notifications); | ||||
|                 await _db.SaveChangesAsync(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}", | ||||
|             notification.Topic, | ||||
|             notification.Id, | ||||
|             notification.Meta | ||||
|         ); | ||||
|  | ||||
|         // WS first | ||||
|         foreach (var account in accounts) | ||||
|         { | ||||
|             notification.AccountId = account; // keep original behavior | ||||
|             _ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket | ||||
|             { | ||||
|                 Type = "notifications.new", | ||||
|                 Data = notification | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         await DeliverPushNotification(notification); | ||||
|     } | ||||
|  | ||||
|     private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification) | ||||
|     { | ||||
|         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(); | ||||
|                     } | ||||
|  | ||||
|                     var fcmResult = await _fcm.SendAsync(new Dictionary<string, object> | ||||
|                     { | ||||
|                         ["message"] = new Dictionary<string, object> | ||||
|                         { | ||||
|                             ["token"] = subscription.DeviceToken, | ||||
|                             ["notification"] = new Dictionary<string, object> | ||||
|                             { | ||||
|                                 ["title"] = notification.Title ?? string.Empty, | ||||
|                                 ["body"] = body | ||||
|                             }, | ||||
|                             // You can re-enable data payloads if needed. | ||||
|                             // ["data"] = new Dictionary<string, object> | ||||
|                             // { | ||||
|                             //     ["Id"] = notification.Id, | ||||
|                             //     ["Topic"] = notification.Topic, | ||||
|                             //     ["Meta"] = notification.Meta | ||||
|                             // } | ||||
|                         } | ||||
|                     }); | ||||
|  | ||||
|                     if (fcmResult.Error != null) | ||||
|                         throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}"); | ||||
|                     break; | ||||
|  | ||||
|                 case PushProvider.Apple: | ||||
|                     if (_apns == null) | ||||
|                         throw new InvalidOperationException("Apple Push Notification Service is not initialized."); | ||||
|  | ||||
|                     var alertDict = new Dictionary<string, object>(); | ||||
|                     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<string, object?> | ||||
|                     { | ||||
|                         ["topic"] = _apnsTopic, | ||||
|                         ["type"] = notification.Topic, | ||||
|                         ["aps"] = new Dictionary<string, object?> | ||||
|                         { | ||||
|                             ["alert"] = alertDict, | ||||
|                             ["sound"] = notification.Priority >= 5 ? "default" : null, | ||||
|                             ["mutable-content"] = 1 | ||||
|                         }, | ||||
|                         ["meta"] = notification.Meta | ||||
|                     }; | ||||
|  | ||||
|                     var apnResult = await _apns.SendAsync( | ||||
|                         payload, | ||||
|                         deviceToken: subscription.DeviceToken, | ||||
|                         apnsId: notification.Id.ToString(), | ||||
|                         apnsPriority: notification.Priority, | ||||
|                         apnPushType: ApnPushType.Alert | ||||
|                     ); | ||||
|                     if (apnResult.Error != null) | ||||
|                         throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}"); | ||||
|  | ||||
|                     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}"); | ||||
|             // Swallow here to keep worker alive; upstream is fire-and-forget. | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             $"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}"); | ||||
|     } | ||||
|  | ||||
|     public async Task SaveNotification(Notification notification) | ||||
|     { | ||||
|         _db.Notifications.Add(notification); | ||||
|         await _db.SaveChangesAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task SaveNotification(Notification notification, List<Guid> accounts) | ||||
|     { | ||||
|         _db.Notifications.AddRange(accounts.Select(a => new Notification | ||||
|         { | ||||
|             AccountId = a, | ||||
|             Topic = notification.Topic, | ||||
|             Content = notification.Content, | ||||
|             Title = notification.Title, | ||||
|             Subtitle = notification.Subtitle, | ||||
|             Meta = notification.Meta, | ||||
|             Priority = notification.Priority, | ||||
|             CreatedAt = notification.CreatedAt, | ||||
|             UpdatedAt = notification.UpdatedAt, | ||||
|         })); | ||||
|         await _db.SaveChangesAsync(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								DysonNetwork.Ring/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								DysonNetwork.Ring/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public enum PushProvider | ||||
| { | ||||
|     Apple, | ||||
|     Google | ||||
| } | ||||
|  | ||||
| [Index(nameof(AccountId), nameof(DeviceId), nameof(DeletedAt), IsUnique = true)] | ||||
| public class PushSubscription : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public Guid AccountId { get; set; } | ||||
|     [MaxLength(8192)] public string DeviceId { get; set; } = null!; | ||||
|     [MaxLength(8192)] public string DeviceToken { get; set; } = null!; | ||||
|     public PushProvider Provider { get; set; } | ||||
|      | ||||
|     public int CountDelivered { get; set; } | ||||
|     public Instant? LastUsedAt { get; set; } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user