[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,146 @@
|
|||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (17ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT "MigrationId", "ProductVersion"
|
||||||
|
FROM "__EFMigrationsHistory"
|
||||||
|
ORDER BY "MigrationId";
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
|
||||||
|
"MigrationId" character varying(150) NOT NULL,
|
||||||
|
"ProductVersion" character varying(32) NOT NULL,
|
||||||
|
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
|
||||||
|
);
|
||||||
|
info: Microsoft.EntityFrameworkCore.Migrations[20411]
|
||||||
|
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT "MigrationId", "ProductVersion"
|
||||||
|
FROM "__EFMigrationsHistory"
|
||||||
|
ORDER BY "MigrationId";
|
||||||
|
info: Microsoft.EntityFrameworkCore.Migrations[20405]
|
||||||
|
No migrations were applied. The database is already up to date.
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Cities" AS c)
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
|
||||||
|
FROM "AppSettings" AS a
|
||||||
|
WHERE a."Id" = 1
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Facilities" AS f
|
||||||
|
WHERE f."IsDemo")
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (15ms) [Parameters=[@p='?' (DbType = Int32), @today='?' (DbType = Date)], CommandType='Text', CommandTimeout='30']
|
||||||
|
UPDATE "Shifts" AS s
|
||||||
|
SET "Status" = @p
|
||||||
|
WHERE s."Status" = 0 AND s."Date" < @today
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (6ms) [Parameters=[@p='?' (DbType = Int32), @jobCutoff='?' (DbType = DateTime)], CommandType='Text', CommandTimeout='30']
|
||||||
|
UPDATE "JobOpenings" AS j
|
||||||
|
SET "Status" = @p
|
||||||
|
WHERE j."Status" = 0 AND j."CreatedAt" < @jobCutoff
|
||||||
|
info: Microsoft.Hosting.Lifetime[14]
|
||||||
|
Now listening on: http://localhost:5077
|
||||||
|
info: Microsoft.Hosting.Lifetime[0]
|
||||||
|
Application started. Press Ctrl+C to shut down.
|
||||||
|
info: Microsoft.Hosting.Lifetime[0]
|
||||||
|
Hosting environment: Development
|
||||||
|
info: Microsoft.Hosting.Lifetime[0]
|
||||||
|
Content root path: F:\Projects\JobsMedical\src\JobsMedical.Web
|
||||||
|
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
|
||||||
|
Failed to determine the https port for redirect.
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[@p='?' (DbType = Int32), @today='?' (DbType = Date)], CommandType='Text', CommandTimeout='30']
|
||||||
|
UPDATE "Shifts" AS s
|
||||||
|
SET "Status" = @p
|
||||||
|
WHERE s."Status" = 0 AND s."Date" < @today
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[@p='?' (DbType = Int32), @jobCutoff='?' (DbType = DateTime)], CommandType='Text', CommandTimeout='30']
|
||||||
|
UPDATE "JobOpenings" AS j
|
||||||
|
SET "Status" = @p
|
||||||
|
WHERE j."Status" = 0 AND j."CreatedAt" < @jobCutoff
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
|
||||||
|
FROM "AppSettings" AS a
|
||||||
|
WHERE a."Id" = 1
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
|
||||||
|
FROM "AppSettings" AS a
|
||||||
|
WHERE a."Id" = 1
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
|
||||||
|
FROM "AppSettings" AS a
|
||||||
|
WHERE a."Id" = 1
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (3ms) [Parameters=[@phone='?'], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT u."Id", u."BanReason", u."CreatedAt", u."FullName", u."IsBanned", u."IsPhoneVerified", u."Phone", u."Role"
|
||||||
|
FROM "Users" AS u
|
||||||
|
WHERE u."Phone" = @phone
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[@vid='?'], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT v."Id", v."CreatedAt", v."LastSeenAt", v."UserId"
|
||||||
|
FROM "Visitors" AS v
|
||||||
|
WHERE v."Id" = @vid
|
||||||
|
LIMIT 1
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (2ms) [Parameters=[@p0='?', @p1='?' (DbType = DateTime), @p2='?' (DbType = DateTime), @p3='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
|
||||||
|
INSERT INTO "Visitors" ("Id", "CreatedAt", "LastSeenAt", "UserId")
|
||||||
|
VALUES (@p0, @p1, @p2, @p3);
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (2ms) [Parameters=[@userId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT count(*)::int
|
||||||
|
FROM "Notifications" AS n
|
||||||
|
WHERE n."UserId" = @userId AND NOT (n."IsRead")
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT u."Id"
|
||||||
|
FROM "Users" AS u
|
||||||
|
WHERE NOT (u."IsBanned")
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (33ms) [Parameters=[@p0='?', @p1='?' (DbType = DateTime), @p2='?' (DbType = Boolean), @p3='?', @p4='?', @p5='?' (DbType = Int32), @p6='?', @p7='?' (DbType = DateTime), @p8='?' (DbType = Boolean), @p9='?', @p10='?', @p11='?' (DbType = Int32), @p12='?', @p13='?' (DbType = DateTime), @p14='?' (DbType = Boolean), @p15='?', @p16='?', @p17='?' (DbType = Int32), @p18='?', @p19='?' (DbType = DateTime), @p20='?' (DbType = Boolean), @p21='?', @p22='?', @p23='?' (DbType = Int32), @p24='?', @p25='?' (DbType = DateTime), @p26='?' (DbType = Boolean), @p27='?', @p28='?', @p29='?' (DbType = Int32), @p30='?', @p31='?' (DbType = DateTime), @p32='?' (DbType = Boolean), @p33='?', @p34='?', @p35='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p0, @p1, @p2, @p3, @p4, @p5)
|
||||||
|
RETURNING "Id";
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p6, @p7, @p8, @p9, @p10, @p11)
|
||||||
|
RETURNING "Id";
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p12, @p13, @p14, @p15, @p16, @p17)
|
||||||
|
RETURNING "Id";
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p18, @p19, @p20, @p21, @p22, @p23)
|
||||||
|
RETURNING "Id";
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p24, @p25, @p26, @p27, @p28, @p29)
|
||||||
|
RETURNING "Id";
|
||||||
|
INSERT INTO "Notifications" ("Body", "CreatedAt", "IsRead", "Title", "Url", "UserId")
|
||||||
|
VALUES (@p30, @p31, @p32, @p33, @p34, @p35)
|
||||||
|
RETURNING "Id";
|
||||||
|
info: JobsMedical.Web.Services.NotificationService[0]
|
||||||
|
Notified 6 users: تست زنده
|
||||||
|
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
|
||||||
|
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
|
||||||
|
SELECT a."Id", a."AiApiKey", a."AiAutoApprove", a."AiEnabled", a."AiEndpoint", a."AiModel", a."AiSystemPrompt", a."AutoIngestEnabled", a."AutoPublishMinConfidence", a."BaleBotToken", a."BaleEnabled", a."DemoMode", a."DivarCity", a."DivarEnabled", a."DivarQueries", a."IngestIntervalMinutes", a."MedjobsEnabled", a."MedjobsMaxAds", a."Mode", a."NeshanMapKey", a."PushEnabled", a."SmsApiKey", a."SmsEnabled", a."SmsSender", a."SmsTemplate", a."TelegramChannels", a."TelegramEnabled", a."UpdatedAt", a."VapidPrivateKey", a."VapidPublicKey", a."VapidSubject", a."WebsiteUrls", a."WebsitesEnabled"
|
||||||
|
FROM "AppSettings" AS a
|
||||||
|
WHERE a."Id" = 1
|
||||||
|
LIMIT 1
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="همکادر" />
|
<meta name="apple-mobile-web-app-title" content="همکادر" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<a class="brand" asp-page="/Index">
|
<a class="brand" asp-page="/Index">
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
@* Always-visible bell on mobile (next to the burger) so notifications stay one tap away *@
|
@* Always-visible bell on mobile (next to the burger) so notifications stay one tap away *@
|
||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<a class="bell-mobile" asp-page="/Me/Notifications" title="اعلانها">🔔@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
<a class="bell-mobile js-bell" asp-page="/Me/Notifications" title="اعلانها">🔔@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
|
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
{
|
{
|
||||||
<a class="nav-action" asp-page="/Employer/Index">پنل کارفرما</a>
|
<a class="nav-action" asp-page="/Employer/Index">پنل کارفرما</a>
|
||||||
}
|
}
|
||||||
<a class="nav-action bell-inline" asp-page="/Me/Notifications" title="اعلانها"><span class="bell-ico">🔔</span><span class="bell-label">اعلانها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلانها"><span class="bell-ico">🔔</span><span class="bell-label">اعلانها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
|
||||||
<a class="nav-action" asp-page="/Me/Index">پنل کارجو</a>
|
<a class="nav-action" asp-page="/Me/Index">پنل کارجو</a>
|
||||||
<form method="post" asp-page="/Account/Logout" style="display:contents;">
|
<form method="post" asp-page="/Account/Logout" style="display:contents;">
|
||||||
<button type="submit" class="btn btn-outline btn-sm">خروج</button>
|
<button type="submit" class="btn btn-outline btn-sm">خروج</button>
|
||||||
@@ -103,12 +103,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<div id="toast-host" class="toast-host" aria-live="polite"></div>
|
||||||
|
|
||||||
@* Register the PWA service worker (offline + push notifications). *@
|
@* Register the PWA service worker (offline + push notifications). *@
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
|
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
|
||||||
|
Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
|
||||||
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var faDigits = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||||
|
function toFa(s){ return String(s).replace(/[0-9]/g, function(d){ return faDigits[+d]; }); }
|
||||||
|
var count = parseInt(document.body.getAttribute('data-unread') || '0', 10) || 0;
|
||||||
|
|
||||||
|
function paintBell() {
|
||||||
|
document.querySelectorAll('.js-bell').forEach(function (bell) {
|
||||||
|
var badge = bell.querySelector('.bell-badge');
|
||||||
|
if (count > 0) {
|
||||||
|
if (!badge) { badge = document.createElement('span'); badge.className = 'bell-badge'; bell.appendChild(badge); }
|
||||||
|
badge.textContent = toFa(count > 99 ? '99+' : count);
|
||||||
|
} else if (badge) { badge.remove(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(n) {
|
||||||
|
var host = document.getElementById('toast-host');
|
||||||
|
if (!host) return;
|
||||||
|
var el = document.createElement('a');
|
||||||
|
el.className = 'toast';
|
||||||
|
el.href = n.url || '/';
|
||||||
|
el.innerHTML = '<span class="toast-ico">🔔</span><span class="toast-body"><strong></strong><span></span></span>';
|
||||||
|
el.querySelector('strong').textContent = n.title || 'همکادر';
|
||||||
|
el.querySelector('.toast-body span').textContent = n.body || '';
|
||||||
|
host.appendChild(el);
|
||||||
|
requestAnimationFrame(function(){ el.classList.add('show'); });
|
||||||
|
setTimeout(function(){ el.classList.remove('show'); setTimeout(function(){ el.remove(); }, 300); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function osNotify(n) {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted' || !navigator.serviceWorker) return;
|
||||||
|
navigator.serviceWorker.ready.then(function (reg) {
|
||||||
|
reg.showNotification(n.title || 'همکادر', {
|
||||||
|
body: n.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png',
|
||||||
|
dir: 'rtl', lang: 'fa', tag: n.url || '/', data: { url: n.url || '/' }
|
||||||
|
});
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('EventSource' in window)) return;
|
||||||
|
var es;
|
||||||
|
function connect() {
|
||||||
|
es = new EventSource('/notifications/stream');
|
||||||
|
es.addEventListener('notice', function (ev) {
|
||||||
|
var n; try { n = JSON.parse(ev.data); } catch (_) { return; }
|
||||||
|
count++; paintBell(); toast(n); osNotify(n);
|
||||||
|
});
|
||||||
|
// EventSource auto-reconnects on transient errors; nothing else needed.
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@await RenderSectionAsync("Scripts", required: false)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ builder.Services.AddSingleton<CaptchaService>();
|
|||||||
builder.Services.AddScoped<SubmissionGuard>();
|
builder.Services.AddScoped<SubmissionGuard>();
|
||||||
builder.Services.AddScoped<NotificationService>();
|
builder.Services.AddScoped<NotificationService>();
|
||||||
builder.Services.AddScoped<PushNotifier>();
|
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.
|
// Listing parser: heuristic now; swap for an LLM-backed IListingParser later.
|
||||||
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
|
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
|
||||||
|
|
||||||
@@ -163,6 +164,50 @@ app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db,
|
|||||||
return Results.Ok();
|
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).
|
// User-submitted report against a listing (abuse/fake/wrong info).
|
||||||
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
|
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
|
||||||
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
|
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
|
||||||
|
|||||||
@@ -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 AppDbContext _db;
|
||||||
private readonly PushNotifier _push;
|
private readonly PushNotifier _push;
|
||||||
|
private readonly NotificationHub _hub;
|
||||||
private readonly ILogger<NotificationService> _log;
|
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;
|
_db = db;
|
||||||
_push = push;
|
_push = push;
|
||||||
|
_hub = hub;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +83,13 @@ public class NotificationService
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
_log.LogInformation("Notified {Count} users: {Title}", userIds.Count, title);
|
_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); }
|
try { await _push.PushToUsersAsync(userIds, title, body, url); }
|
||||||
catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); }
|
catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-toggle:checked ~ .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
.nav-toggle:checked ~ .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||||
.bell-mobile { position: relative; font-size: 20px; margin-inline-start: auto; line-height: 1; }
|
.bell-mobile { position: relative; font-size: 20px; margin-inline-start: auto; line-height: 1; }
|
||||||
|
|
||||||
|
/* ---------- Live notification toasts (SSE) ---------- */
|
||||||
|
.toast-host {
|
||||||
|
position: fixed; inset-block-end: 16px; inset-inline-start: 16px; z-index: 200;
|
||||||
|
display: flex; flex-direction: column; gap: 10px; max-width: min(360px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-inline-start: 4px solid var(--primary);
|
||||||
|
border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.16); color: var(--text);
|
||||||
|
opacity: 0; transform: translateY(12px); transition: opacity .25s, transform .25s;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateY(0); }
|
||||||
|
.toast-ico { font-size: 18px; line-height: 1.4; }
|
||||||
|
.toast-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.toast-body strong { font-size: 14px; }
|
||||||
|
.toast-body span { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* ---------- Buttons ---------- */
|
/* ---------- Buttons ---------- */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user