From b46bd49c32764910fbf4b1921aade9a2a5d15b6a Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 12:23:50 +0330 Subject: [PATCH] Wire Web Push broadcaster: lock-screen pushes ride the in-app notifications - nuget.config with Soroush Nexus + Liara mirrors (nuget.org filtered); added WebPush 1.0.12 - PushNotifier: VAPID send to a user's subscriptions, prunes dead (404/410); config from AppSetting - NotificationService fans out a Web Push to matched users' subscribed browsers after creating in-app notifications (best-effort; no-op until admin enables push + sets VAPID) - Build verified through the mirrors; app boots with PushNotifier wired Co-Authored-By: Claude Opus 4.8 --- nuget.config | 13 ++++ src/JobsMedical.Web/JobsMedical.Web.csproj | 1 + src/JobsMedical.Web/Program.cs | 1 + .../Services/NotificationService.cs | 8 ++- src/JobsMedical.Web/Services/PushNotifier.cs | 69 +++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 nuget.config create mode 100644 src/JobsMedical.Web/Services/PushNotifier.cs diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..7473e94 --- /dev/null +++ b/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/JobsMedical.Web/JobsMedical.Web.csproj b/src/JobsMedical.Web/JobsMedical.Web.csproj index 614e444..efa5057 100644 --- a/src/JobsMedical.Web/JobsMedical.Web.csproj +++ b/src/JobsMedical.Web/JobsMedical.Web.csproj @@ -12,6 +12,7 @@ all + diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index aff6886..d6d10a6 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -24,6 +24,7 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Listing parser: heuristic now; swap for an LLM-backed IListingParser later. builder.Services.AddSingleton(); diff --git a/src/JobsMedical.Web/Services/NotificationService.cs b/src/JobsMedical.Web/Services/NotificationService.cs index e5477df..2cad7ee 100644 --- a/src/JobsMedical.Web/Services/NotificationService.cs +++ b/src/JobsMedical.Web/Services/NotificationService.cs @@ -12,11 +12,13 @@ namespace JobsMedical.Web.Services; public class NotificationService { private readonly AppDbContext _db; + private readonly PushNotifier _push; private readonly ILogger _log; - public NotificationService(AppDbContext db, ILogger log) + public NotificationService(AppDbContext db, PushNotifier push, ILogger log) { _db = db; + _push = push; _log = log; } @@ -74,5 +76,9 @@ public class NotificationService _db.Notifications.Add(new Notification { UserId = uid, Title = title, Body = body, Url = url }); await _db.SaveChangesAsync(); _log.LogInformation("Notified {Count} users: {Title}", userIds.Count, title); + + // Also push to the lock screen for users who subscribed (best-effort). + try { await _push.PushToUsersAsync(userIds, title, body, url); } + catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); } } } diff --git a/src/JobsMedical.Web/Services/PushNotifier.cs b/src/JobsMedical.Web/Services/PushNotifier.cs new file mode 100644 index 0000000..2ec9dee --- /dev/null +++ b/src/JobsMedical.Web/Services/PushNotifier.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using JobsMedical.Web.Services.Scraping; +using Microsoft.EntityFrameworkCore; +using WebPush; + +namespace JobsMedical.Web.Services; + +/// +/// Sends Web Push notifications (lock-screen) to users' subscribed browsers via VAPID. Rides the +/// same in-app notification records — when users are notified, their subscriptions also get a push. +/// Config (enable + VAPID keys) comes from AppSetting; dead subscriptions (404/410) are pruned. +/// ⚠️ Chrome push goes through FCM (may be filtered in Iran); the Bazaar TWA can use native push. +/// +public class PushNotifier +{ + private readonly AppDbContext _db; + private readonly SettingsService _settings; + private readonly ILogger _log; + + public PushNotifier(AppDbContext db, SettingsService settings, ILogger log) + { + _db = db; + _settings = settings; + _log = log; + } + + public async Task PushToUsersAsync(IReadOnlyCollection userIds, string title, string? body, string url) + { + if (userIds.Count == 0) return; + var s = await _settings.GetAsync(); + if (!s.PushEnabled || string.IsNullOrWhiteSpace(s.VapidPublicKey) || string.IsNullOrWhiteSpace(s.VapidPrivateKey)) + return; + + var subs = await (from sub in _db.WebPushSubscriptions + join v in _db.Visitors on sub.VisitorId equals v.Id + where v.UserId != null && userIds.Contains(v.UserId.Value) + select sub).ToListAsync(); + if (subs.Count == 0) return; + + var client = new WebPushClient(); + var vapid = new VapidDetails(s.VapidSubject ?? "mailto:admin@hamkadr.ir", s.VapidPublicKey, s.VapidPrivateKey); + var payload = JsonSerializer.Serialize(new { title, body, url }); + + var dead = new List(); + foreach (var sub in subs) + { + try + { + await client.SendNotificationAsync( + new WebPush.PushSubscription(sub.Endpoint, sub.P256dh, sub.Auth), payload, vapid); + } + catch (WebPushException ex) when ((int)ex.StatusCode is 404 or 410) + { + dead.Add(sub); // subscription expired/gone + } + catch (Exception ex) + { + _log.LogWarning(ex, "Web push send failed for one subscription"); + } + } + if (dead.Count > 0) + { + _db.WebPushSubscriptions.RemoveRange(dead); + await _db.SaveChangesAsync(); + } + } +}