[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
+45
View File
@@ -26,6 +26,7 @@ builder.Services.AddSingleton<CaptchaService>();
builder.Services.AddScoped<SubmissionGuard>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<PushNotifier>();
builder.Services.AddSingleton<NotificationHub>(); // in-memory SSE broker (live in-app notifications)
// Listing parser: heuristic now; swap for an LLM-backed IListingParser later.
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
@@ -163,6 +164,50 @@ app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db,
return Results.Ok();
});
// Live notification stream (Server-Sent Events). Runs over our own origin, so it reaches
// users in Iran (unlike Web Push, which goes via the browser's blocked push service).
// The browser keeps this open while the tab/PWA is alive; the client updates the bell,
// shows a toast, and fires a local OS notification (no push server) when permission is on.
app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub) =>
{
var claim = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(claim, out var uid)) { ctx.Response.StatusCode = 401; return; }
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // tell nginx not to buffer the stream
ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>()?.DisableBuffering();
var (reader, unsubscribe) = hub.Subscribe(uid);
var ct = ctx.RequestAborted;
try
{
await ctx.Response.WriteAsync(": connected\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
while (!ct.IsCancellationRequested)
{
var readTask = reader.WaitToReadAsync(ct).AsTask();
var keepAlive = Task.Delay(TimeSpan.FromSeconds(25), ct);
if (await Task.WhenAny(readTask, keepAlive) == keepAlive)
{
await ctx.Response.WriteAsync(": ping\n\n", ct); // comment line keeps the connection warm
await ctx.Response.Body.FlushAsync(ct);
continue;
}
if (!await readTask) break;
while (reader.TryRead(out var notice))
{
var json = System.Text.Json.JsonSerializer.Serialize(notice);
await ctx.Response.WriteAsync($"event: notice\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
}
catch (OperationCanceledException) { /* client disconnected — normal */ }
finally { unsubscribe(); }
}).RequireAuthorization();
// User-submitted report against a listing (abuse/fake/wrong info).
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,