[Notify] Add live in-app notifications over SSE (Iran-friendly)
CI/CD / CI · dotnet build (push) Has been cancelled
CI/CD / Deploy · hamkadr (push) Has been cancelled

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:
soroush.asadi
2026-06-04 15:42:16 +03:30
parent d2a7b18cb3
commit 716433ce20
7 changed files with 343 additions and 5 deletions
@@ -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"); }
}