[Notify] Add live in-app notifications over SSE (Iran-friendly)
Web Push is delivered by the browser vendor's push service (Chrome to Google FCM), which is filtered in Iran, so background push is unreliable. Add a Server-Sent Events channel over our own origin that always reaches users while the tab/PWA is open: NotificationHub (in-memory pub/sub), a /notifications/stream SSE endpoint (auth-gated, keep-alive pings, nginx no-buffer), and NotificationService now publishes each saved notification to the hub. Client updates the bell badge instantly, shows a toast, and fires a local OS notification via the service worker when permission is granted (no push server). Web Push stays as best-effort for closed-app reach. Verified end-to-end: login, open stream, broadcast, event delivered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory pub/sub for live notifications over Server-Sent Events (SSE).
|
||||
///
|
||||
/// Why SSE instead of Web Push here: Web Push is delivered by the *browser vendor's*
|
||||
/// push service (Chrome → Google FCM), which is filtered in Iran. SSE streams over our
|
||||
/// OWN origin (hamkadr.ir), so it always reaches users while the tab/PWA is open — no
|
||||
/// Google dependency. The client then shows an in-page toast and (if permission is
|
||||
/// granted) a LOCAL OS notification via the service worker — also no push server.
|
||||
///
|
||||
/// Singleton, process-local. Each open tab = one subscription. Web Push stays as the
|
||||
/// best-effort closed-app channel for users who can reach the push endpoint.
|
||||
/// </summary>
|
||||
public class NotificationHub
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, ConcurrentDictionary<Guid, Channel<LiveNotice>>> _subs = new();
|
||||
|
||||
/// <summary>Open a stream for a user. Returns the reader + an unsubscribe callback.</summary>
|
||||
public (ChannelReader<LiveNotice> Reader, Action Unsubscribe) Subscribe(int userId)
|
||||
{
|
||||
// Bounded + DropOldest so a stalled/slow client can never grow memory unbounded.
|
||||
var ch = Channel.CreateBounded<LiveNotice>(new BoundedChannelOptions(50)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
var id = Guid.NewGuid();
|
||||
var map = _subs.GetOrAdd(userId, _ => new());
|
||||
map[id] = ch;
|
||||
|
||||
void Unsub()
|
||||
{
|
||||
if (_subs.TryGetValue(userId, out var m))
|
||||
{
|
||||
m.TryRemove(id, out _);
|
||||
if (m.IsEmpty) _subs.TryRemove(userId, out _);
|
||||
}
|
||||
ch.Writer.TryComplete();
|
||||
}
|
||||
return (ch.Reader, Unsub);
|
||||
}
|
||||
|
||||
/// <summary>Fan a notice out to every open tab of the given user (no-op if none online).</summary>
|
||||
public void Publish(int userId, LiveNotice notice)
|
||||
{
|
||||
if (_subs.TryGetValue(userId, out var m))
|
||||
foreach (var ch in m.Values)
|
||||
ch.Writer.TryWrite(notice);
|
||||
}
|
||||
|
||||
/// <summary>True if the user has at least one open SSE stream right now.</summary>
|
||||
public bool IsOnline(int userId) => _subs.ContainsKey(userId);
|
||||
}
|
||||
|
||||
/// <summary>Payload pushed to the browser over SSE (serialized to the event's data line).</summary>
|
||||
public record LiveNotice(string title, string? body, string url);
|
||||
@@ -13,12 +13,14 @@ public class NotificationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly PushNotifier _push;
|
||||
private readonly NotificationHub _hub;
|
||||
private readonly ILogger<NotificationService> _log;
|
||||
|
||||
public NotificationService(AppDbContext db, PushNotifier push, ILogger<NotificationService> log)
|
||||
public NotificationService(AppDbContext db, PushNotifier push, NotificationHub hub, ILogger<NotificationService> log)
|
||||
{
|
||||
_db = db;
|
||||
_push = push;
|
||||
_hub = hub;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -81,7 +83,13 @@ public class NotificationService
|
||||
await _db.SaveChangesAsync();
|
||||
_log.LogInformation("Notified {Count} users: {Title}", userIds.Count, title);
|
||||
|
||||
// Also push to the lock screen for users who subscribed (best-effort).
|
||||
// Live: stream to any open tab/PWA over SSE (our own origin — works in Iran).
|
||||
// The browser updates the bell instantly + shows a local toast/OS notification.
|
||||
var notice = new LiveNotice(title, body, url);
|
||||
foreach (var uid in userIds) _hub.Publish(uid, notice);
|
||||
|
||||
// Also push to the lock screen for users who subscribed (best-effort; Web Push
|
||||
// depends on the browser's push service, which is filtered in Iran for Chromium).
|
||||
try { await _push.PushToUsersAsync(userIds, title, body, url); }
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user