From 716433ce2047616f800e29ba42dc7763b566c0d8 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 15:42:16 +0330 Subject: [PATCH] [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 --- run.err | 0 run.log | 146 ++++++++++++++++++ .../Pages/Shared/_Layout.cshtml | 67 +++++++- src/JobsMedical.Web/Program.cs | 45 ++++++ .../Services/NotificationHub.cs | 61 ++++++++ .../Services/NotificationService.cs | 12 +- src/JobsMedical.Web/wwwroot/css/site.css | 17 ++ 7 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 run.err create mode 100644 run.log create mode 100644 src/JobsMedical.Web/Services/NotificationHub.cs diff --git a/run.err b/run.err new file mode 100644 index 0000000..e69de29 diff --git a/run.log b/run.log new file mode 100644 index 0000000..f69e044 --- /dev/null +++ b/run.log @@ -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 diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 366de33..9929750 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -29,7 +29,7 @@ - +