Wire Web Push broadcaster: lock-screen pushes ride the in-app notifications
CI/CD / CI · dotnet build (push) Failing after 1m40s
CI/CD / Deploy · hamkadr (push) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 12:23:50 +03:30
parent 10d4727bd5
commit b46bd49c32
5 changed files with 91 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!-- Soroush Nexus mirror (primary) + Liara mirror (fallback) — nuget.org is filtered. -->
<add key="nexus" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
<add key="liara" value="https://package-mirror.liara.ir/repository/nuget/index.json" protocolVersion="3" />
</packageSources>
<config>
<add key="http_retry_count" value="6" />
<add key="http_retry_delay_milliseconds" value="1000" />
</config>
</configuration>
@@ -12,6 +12,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="WebPush" Version="1.0.12" />
</ItemGroup>
</Project>
+1
View File
@@ -24,6 +24,7 @@ builder.Services.AddScoped<OtpService>();
builder.Services.AddSingleton<CaptchaService>();
builder.Services.AddScoped<SubmissionGuard>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<PushNotifier>();
// Listing parser: heuristic now; swap for an LLM-backed IListingParser later.
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
@@ -12,11 +12,13 @@ namespace JobsMedical.Web.Services;
public class NotificationService
{
private readonly AppDbContext _db;
private readonly PushNotifier _push;
private readonly ILogger<NotificationService> _log;
public NotificationService(AppDbContext db, ILogger<NotificationService> log)
public NotificationService(AppDbContext db, PushNotifier push, ILogger<NotificationService> 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"); }
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class PushNotifier
{
private readonly AppDbContext _db;
private readonly SettingsService _settings;
private readonly ILogger<PushNotifier> _log;
public PushNotifier(AppDbContext db, SettingsService settings, ILogger<PushNotifier> log)
{
_db = db;
_settings = settings;
_log = log;
}
public async Task PushToUsersAsync(IReadOnlyCollection<int> 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<WebPushSubscription>();
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();
}
}
}