[Admin] Notification channel toggles (web/SMS/push active-deactive)
CI/CD / CI · dotnet build (push) Successful in 50s
CI/CD / Deploy · hamkadr (push) Successful in 1m1s

Add a 'notification channels' card at the top of admin Settings with three master on/off checkboxes: web/in-app (new WebNotificationsEnabled, default true), SMS (existing SmsEnabled), and Web Push (existing PushEnabled). Removed the duplicate enable checkboxes from the SMS and Push sections so each binds once. NotificationService now gates the in-app + live SSE channel on WebNotificationsEnabled; push self-gates on PushEnabled. Migration defaults the new column to true so existing installs keep web notifications on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 15:56:40 +03:30
parent 91c953ff5d
commit 8fad9c1bb6
8 changed files with 1154 additions and 22 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class WebNotificationsToggle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Default TRUE so existing installs keep the in-app/web channel ON after upgrade
// (matches AppSetting.WebNotificationsEnabled = true).
migrationBuilder.AddColumn<bool>(
name: "WebNotificationsEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WebNotificationsEnabled",
table: "AppSettings");
}
}
}
@@ -136,6 +136,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<bool>("WebNotificationsEnabled")
.HasColumnType("boolean");
b.Property<string>("WebsiteUrls")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
+5
View File
@@ -73,6 +73,11 @@ public class AppSetting
/// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown.</summary>
[MaxLength(200)] public string? NeshanMapKey { get; set; }
// --- Notification channels (master on/off, controlled from the admin panel) ---
/// <summary>Live in-app / web notifications (SSE bell + toast + local OS popup). Works in Iran
/// because it streams over our own origin — no external push service. On by default.</summary>
public bool WebNotificationsEnabled { get; set; } = true;
// --- Web Push (PWA notifications). VAPID keypair; generate once with the web-push tooling. ---
public bool PushEnabled { get; set; } = false;
[MaxLength(200)] public string? VapidPublicKey { get; set; }
+28 -12
View File
@@ -34,6 +34,32 @@
<div class="alert alert-success">✓ @Model.Saved</div>
}
<form method="post" class="card card-pad">
<h3 style="margin-top:0;">کانال‌های اعلان (فعال / غیرفعال)</h3>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن هر کانال ارسال اعلان به کاربران. کلیدها و تنظیمات هر کانال در بخش‌های پایین‌تر.</p>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="WebNotificationsEnabled" value="true" style="width:auto;" checked="@Model.WebNotificationsEnabled" />
اعلان‌های وب / درون‌برنامه‌ای (زنگوله + نوتیف زنده) — توصیه‌شده برای ایران
</label>
<p class="muted" style="font-size:12px; margin:4px 0 0;">از طریق سرور خودمان ارسال می‌شود؛ نیازی به سرویس‌های گوگل ندارد و در ایران کار می‌کند.</p>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="SmsEnabled" value="true" style="width:auto;" checked="@Model.SmsEnabled" />
پیامک (SMS) — کاوه‌نگار
</label>
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای کد ورود و اعلان‌های مهم. کلید و تمپلیت را در بخش «پیامک ورود» پایین وارد کن.</p>
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="PushEnabled" value="true" style="width:auto;" checked="@Model.PushEnabled" />
پوش مرورگر (Web Push) — بهترین تلاش
</label>
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای اعلان هنگام بسته‌بودن برنامه؛ ولی از سرویس مرورگر (گوگل) عبور می‌کند که در ایران اغلب فیلتر است.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">حالت انتشار</h3>
<div class="filter-group">
<label>نحوه افزودن آگهی‌ها به سایت</label>
@@ -156,12 +182,7 @@
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوه‌نگار</h3>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="SmsEnabled" value="true" style="width:auto;" checked="@Model.SmsEnabled" />
ارسال کد ورود با پیامک (در صورت خاموش بودن، کد روی صفحه نمایش داده می‌شود)
</label>
</div>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن این کانال در بخش «کانال‌های اعلان» بالا. (در صورت خاموش بودن، کد ورود روی صفحه نمایش داده می‌شود.)</p>
<div class="filter-group">
<label>کلید API کاوه‌نگار</label>
<input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" />
@@ -182,12 +203,7 @@
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">اعلان‌ها (Web Push / PWA)</h3>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="PushEnabled" value="true" style="width:auto;" checked="@Model.PushEnabled" />
فعال‌سازی اشتراک اعلان مرورگری
</label>
</div>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن این کانال در بخش «کانال‌های اعلان» بالا. اینجا فقط کلیدهای VAPID را وارد کن.</p>
<div class="filter-group">
<label>VAPID Public Key</label>
<input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" />
@@ -46,6 +46,7 @@ public class SettingsModel : PageModel
[BindProperty] public string? SmsTemplate { get; set; }
[BindProperty] public string? SmsSender { get; set; }
[BindProperty] public string? NeshanMapKey { get; set; }
[BindProperty] public bool WebNotificationsEnabled { get; set; }
[BindProperty] public bool PushEnabled { get; set; }
[BindProperty] public string? VapidPublicKey { get; set; }
[BindProperty] public string? VapidPrivateKey { get; set; }
@@ -88,6 +89,7 @@ public class SettingsModel : PageModel
DemoMode = s.DemoMode;
WebsitesEnabled = s.WebsitesEnabled;
WebsiteUrls = s.WebsiteUrls;
WebNotificationsEnabled = s.WebNotificationsEnabled;
PushEnabled = s.PushEnabled;
VapidPublicKey = s.VapidPublicKey;
VapidPrivateKey = s.VapidPrivateKey;
@@ -125,6 +127,7 @@ public class SettingsModel : PageModel
DemoMode = DemoMode,
WebsitesEnabled = WebsitesEnabled,
WebsiteUrls = WebsiteUrls,
WebNotificationsEnabled = WebNotificationsEnabled,
PushEnabled = PushEnabled,
VapidPublicKey = VapidPublicKey,
VapidPrivateKey = VapidPrivateKey,
@@ -78,18 +78,26 @@ public class NotificationService
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
{
if (userIds.Count == 0) return;
foreach (var uid in userIds)
_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);
// 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);
var settings = await _db.AppSettings.FindAsync(1);
var webOn = settings?.WebNotificationsEnabled ?? true; // master toggle for the in-app/web channel
// 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).
// Web / in-app notifications channel (bell list + live SSE). Admin can turn it off.
if (webOn)
{
foreach (var uid in userIds)
_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);
// 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);
}
// Web Push channel (lock screen). Self-gates on PushEnabled + VAPID keys; best-effort
// since Web Push depends on the browser's push service, which is filtered in Iran.
try { await _push.PushToUsersAsync(userIds, title, body, url); }
catch (Exception ex) { _log.LogWarning(ex, "Web push fan-out failed"); }
}
@@ -54,6 +54,7 @@ public class SettingsService
s.SmsTemplate = incoming.SmsTemplate?.Trim();
s.SmsSender = incoming.SmsSender?.Trim();
s.NeshanMapKey = incoming.NeshanMapKey?.Trim();
s.WebNotificationsEnabled = incoming.WebNotificationsEnabled;
s.PushEnabled = incoming.PushEnabled;
s.VapidPublicKey = incoming.VapidPublicKey?.Trim();
s.VapidPrivateKey = incoming.VapidPrivateKey?.Trim();