[Admin] Notification channel toggles (web/SMS/push active-deactive)
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:
+1065
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)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("character varying(120)");
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<bool>("WebNotificationsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("WebsiteUrls")
|
b.Property<string>("WebsiteUrls")
|
||||||
.HasMaxLength(4000)
|
.HasMaxLength(4000)
|
||||||
.HasColumnType("character varying(4000)");
|
.HasColumnType("character varying(4000)");
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ public class AppSetting
|
|||||||
/// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown.</summary>
|
/// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown.</summary>
|
||||||
[MaxLength(200)] public string? NeshanMapKey { get; set; }
|
[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. ---
|
// --- Web Push (PWA notifications). VAPID keypair; generate once with the web-push tooling. ---
|
||||||
public bool PushEnabled { get; set; } = false;
|
public bool PushEnabled { get; set; } = false;
|
||||||
[MaxLength(200)] public string? VapidPublicKey { get; set; }
|
[MaxLength(200)] public string? VapidPublicKey { get; set; }
|
||||||
|
|||||||
@@ -34,6 +34,32 @@
|
|||||||
<div class="alert alert-success">✓ @Model.Saved</div>
|
<div class="alert alert-success">✓ @Model.Saved</div>
|
||||||
}
|
}
|
||||||
<form method="post" class="card card-pad">
|
<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>
|
<h3 style="margin-top:0;">حالت انتشار</h3>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>نحوه افزودن آگهیها به سایت</label>
|
<label>نحوه افزودن آگهیها به سایت</label>
|
||||||
@@ -156,12 +182,7 @@
|
|||||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||||
|
|
||||||
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوهنگار</h3>
|
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوهنگار</h3>
|
||||||
<div class="filter-group">
|
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در بخش «کانالهای اعلان» بالا. (در صورت خاموش بودن، کد ورود روی صفحه نمایش داده میشود.)</p>
|
||||||
<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>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>کلید API کاوهنگار</label>
|
<label>کلید API کاوهنگار</label>
|
||||||
<input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" />
|
<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;" />
|
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||||
<h3 style="margin-top:0;">اعلانها (Web Push / PWA)</h3>
|
<h3 style="margin-top:0;">اعلانها (Web Push / PWA)</h3>
|
||||||
<div class="filter-group">
|
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در بخش «کانالهای اعلان» بالا. اینجا فقط کلیدهای VAPID را وارد کن.</p>
|
||||||
<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>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>VAPID Public Key</label>
|
<label>VAPID Public Key</label>
|
||||||
<input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" />
|
<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? SmsTemplate { get; set; }
|
||||||
[BindProperty] public string? SmsSender { get; set; }
|
[BindProperty] public string? SmsSender { get; set; }
|
||||||
[BindProperty] public string? NeshanMapKey { get; set; }
|
[BindProperty] public string? NeshanMapKey { get; set; }
|
||||||
|
[BindProperty] public bool WebNotificationsEnabled { get; set; }
|
||||||
[BindProperty] public bool PushEnabled { get; set; }
|
[BindProperty] public bool PushEnabled { get; set; }
|
||||||
[BindProperty] public string? VapidPublicKey { get; set; }
|
[BindProperty] public string? VapidPublicKey { get; set; }
|
||||||
[BindProperty] public string? VapidPrivateKey { get; set; }
|
[BindProperty] public string? VapidPrivateKey { get; set; }
|
||||||
@@ -88,6 +89,7 @@ public class SettingsModel : PageModel
|
|||||||
DemoMode = s.DemoMode;
|
DemoMode = s.DemoMode;
|
||||||
WebsitesEnabled = s.WebsitesEnabled;
|
WebsitesEnabled = s.WebsitesEnabled;
|
||||||
WebsiteUrls = s.WebsiteUrls;
|
WebsiteUrls = s.WebsiteUrls;
|
||||||
|
WebNotificationsEnabled = s.WebNotificationsEnabled;
|
||||||
PushEnabled = s.PushEnabled;
|
PushEnabled = s.PushEnabled;
|
||||||
VapidPublicKey = s.VapidPublicKey;
|
VapidPublicKey = s.VapidPublicKey;
|
||||||
VapidPrivateKey = s.VapidPrivateKey;
|
VapidPrivateKey = s.VapidPrivateKey;
|
||||||
@@ -125,6 +127,7 @@ public class SettingsModel : PageModel
|
|||||||
DemoMode = DemoMode,
|
DemoMode = DemoMode,
|
||||||
WebsitesEnabled = WebsitesEnabled,
|
WebsitesEnabled = WebsitesEnabled,
|
||||||
WebsiteUrls = WebsiteUrls,
|
WebsiteUrls = WebsiteUrls,
|
||||||
|
WebNotificationsEnabled = WebNotificationsEnabled,
|
||||||
PushEnabled = PushEnabled,
|
PushEnabled = PushEnabled,
|
||||||
VapidPublicKey = VapidPublicKey,
|
VapidPublicKey = VapidPublicKey,
|
||||||
VapidPrivateKey = VapidPrivateKey,
|
VapidPrivateKey = VapidPrivateKey,
|
||||||
|
|||||||
@@ -78,18 +78,26 @@ public class NotificationService
|
|||||||
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
|
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
|
||||||
{
|
{
|
||||||
if (userIds.Count == 0) return;
|
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).
|
var settings = await _db.AppSettings.FindAsync(1);
|
||||||
// The browser updates the bell instantly + shows a local toast/OS notification.
|
var webOn = settings?.WebNotificationsEnabled ?? true; // master toggle for the in-app/web channel
|
||||||
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
|
// Web / in-app notifications channel (bell list + live SSE). Admin can turn it off.
|
||||||
// depends on the browser's push service, which is filtered in Iran for Chromium).
|
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); }
|
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"); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public class SettingsService
|
|||||||
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
||||||
s.SmsSender = incoming.SmsSender?.Trim();
|
s.SmsSender = incoming.SmsSender?.Trim();
|
||||||
s.NeshanMapKey = incoming.NeshanMapKey?.Trim();
|
s.NeshanMapKey = incoming.NeshanMapKey?.Trim();
|
||||||
|
s.WebNotificationsEnabled = incoming.WebNotificationsEnabled;
|
||||||
s.PushEnabled = incoming.PushEnabled;
|
s.PushEnabled = incoming.PushEnabled;
|
||||||
s.VapidPublicKey = incoming.VapidPublicKey?.Trim();
|
s.VapidPublicKey = incoming.VapidPublicKey?.Trim();
|
||||||
s.VapidPrivateKey = incoming.VapidPrivateKey?.Trim();
|
s.VapidPrivateKey = incoming.VapidPrivateKey?.Trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user