Allow disabling notifications per user and disabling specific notifications per user (#1991)

* Allow disabling notifications per user and disabling specific notifications per use

closes #1974

* Add disable notifs for all users

* fix term generator for notifications

* sow checkboxes instead of multiselect when js is enabled

* remove js dependency

* fix notif conditions
This commit is contained in:
Andrew Camilleri 2020-10-20 13:09:09 +02:00 committed by GitHub
parent 933e0c30bf
commit 4d0b402e8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 315 additions and 64 deletions

View file

@ -2,10 +2,17 @@ using System;
namespace BTCPayServer.Contracts
{
public abstract class BaseNotification
{
public abstract string Identifier { get; }
public abstract string NotificationType { get; }
}
public interface INotificationHandler
{
string NotificationType { get; }
Type NotificationBlobType { get; }
public (string identifier, string name)[] Meta { get; }
void FillViewModel(object notification, NotificationViewModel vm);
}

View file

@ -28,5 +28,6 @@ namespace BTCPayServer.Data
public List<U2FDevice> U2FDevices { get; set; }
public List<APIKeyData> APIKeys { get; set; }
public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
}
}

View file

@ -0,0 +1,26 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20201015151438_AddDisabledNotificationsToUser")]
public partial class AddDisabledNotificationsToUser : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DisabledNotifications",
table: "AspNetUsers",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DisabledNotifications",
table: "AspNetUsers");
}
}
}

View file

@ -111,6 +111,9 @@ namespace BTCPayServer.Migrations
b.Property<DateTimeOffset?>("Created")
.HasColumnType("TEXT");
b.Property<string>("DisabledNotifications")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasMaxLength(256);

View file

@ -1,4 +1,7 @@
@inject LinkGenerator linkGenerator
@inject UserManager<ApplicationUser> UserManager
@inject CssThemeManager CssThemeManager
@using BTCPayServer.HostedServices
@model BTCPayServer.Components.NotificationsDropdown.NotificationSummaryViewModel
@if (Model.UnseenCount > 0)
@ -32,36 +35,47 @@ else
</a>
</li>
}
<script type="text/javascript">
@{
var disabled = CssThemeManager.Policies.DisableInstantNotifications;
if (!disabled)
{
var user = await UserManager.GetUserAsync(User);
disabled = user.DisabledNotifications == "all";
}
}
@if (!disabled)
{
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
<script type="text/javascript">
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
if (supportsWebSockets) {
var loc = window.location, ws_uri;
if (loc.protocol === "https:") {
ws_uri = "wss:";
} else {
ws_uri = "ws:";
}
ws_uri += "//" + loc.host;
ws_uri += "@linkGenerator.GetPathByAction("SubscribeUpdates", "Notifications")";
var newDataEndpoint = "@linkGenerator.GetPathByAction("GetNotificationDropdownUI", "Notifications")";
try {
socket = new WebSocket(ws_uri);
socket.onmessage = function (e) {
$.get(newDataEndpoint, function(data){
$("#notifications-nav-item").replaceWith($(data));
});
};
socket.onerror = function (e) {
console.error("Error while connecting to websocket for notifications (callback)", e);
};
}
catch (e) {
console.error("Error while connecting to websocket for notifications", e);
var loc = window.location, ws_uri;
if (loc.protocol === "https:") {
ws_uri = "wss:";
} else {
ws_uri = "ws:";
}
ws_uri += "//" + loc.host;
ws_uri += "@linkGenerator.GetPathByAction("SubscribeUpdates", "Notifications")";
var newDataEndpoint = "@linkGenerator.GetPathByAction("GetNotificationDropdownUI", "Notifications")";
try {
socket = new WebSocket(ws_uri);
socket.onmessage = function (e) {
$.get(newDataEndpoint, function(data){
$("#notifications-nav-item").replaceWith($(data));
});
};
socket.onerror = function (e) {
console.error("Error while connecting to websocket for notifications (callback)", e);
};
}
catch (e) {
console.error("Error while connecting to websocket for notifications", e);
}
}
</script>
}
</script>

View file

@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Contracts;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Controllers
{
public partial class ManageController
{
[HttpGet("notifications")]
public async Task<IActionResult> NotificationSettings([FromServices] IEnumerable<INotificationHandler> notificationHandlers)
{
var user = await _userManager.GetUserAsync(User);
if (user.DisabledNotifications == "all")
{
return View(new NotificationSettingsViewModel() {All = true});
}
var disabledNotifications =
user.DisabledNotifications?.Split(';', StringSplitOptions.RemoveEmptyEntries)?.ToList() ??
new List<string>();
var notifications = notificationHandlers.SelectMany(handler => handler.Meta.Select(tuple =>
new SelectListItem(tuple.name, tuple.identifier,
disabledNotifications.Contains(tuple.identifier, StringComparer.InvariantCultureIgnoreCase))))
.ToList();
return View(new NotificationSettingsViewModel() {DisabledNotifications = notifications});
}
[HttpPost("notifications")]
public async Task<IActionResult> NotificationSettings(NotificationSettingsViewModel vm, string command)
{
var user = await _userManager.GetUserAsync(User);
if (command == "disable-all")
{
user.DisabledNotifications = "all";
}
else if (command == "enable-all")
{
user.DisabledNotifications = "";
}
else if (command == "update")
{
var disabled = vm.DisabledNotifications.Where(item => item.Selected).Select(item => item.Value)
.ToArray();
user.DisabledNotifications = disabled.Any() is true
? string.Join(';', disabled) + ";"
: string.Empty;
}
await _userManager.UpdateAsync(user);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Updated successfully.", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("NotificationSettings");
}
public class NotificationSettingsViewModel
{
public bool All { get; set; }
public List<SelectListItem> DisabledNotifications { get; set; }
}
}
}

View file

@ -89,11 +89,11 @@ namespace BTCPayServer.Controllers
}
#if DEBUG
[HttpGet]
public async Task<IActionResult> GenerateJunk(int x = 100)
public async Task<IActionResult> GenerateJunk(int x = 100, bool admin=true)
{
for (int i = 0; i < x; i++)
{
await _notificationSender.SendNotification(new AdminScope(), new JunkNotification());
await _notificationSender.SendNotification(admin? (NotificationScope) new AdminScope(): new UserScope(_userManager.GetUserId(User)), new JunkNotification());
}
return RedirectToAction("Index");

View file

@ -9,8 +9,9 @@ using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs
{
internal class InvoiceEventNotification
internal class InvoiceEventNotification:BaseNotification
{
private const string TYPE = "invoicestate";
internal class Handler : NotificationHandler<InvoiceEventNotification>
{
private readonly LinkGenerator _linkGenerator;
@ -22,7 +23,16 @@ namespace BTCPayServer.Services.Notifications.Blobs
_options = options;
}
public override string NotificationType => "invoicestate";
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "All invoice updates"),}
.Concat(TextMapping.Select(pair => ($"{TYPE}_{pair.Key}", $"Invoice {pair.Value}"))).ToArray();
}
}
internal static Dictionary<string, string> TextMapping = new Dictionary<string, string>()
{
@ -65,5 +75,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string InvoiceId { get; set; }
public string Event { get; set; }
public override string Identifier => $"{TYPE}_{Event}";
public override string NotificationType => TYPE;
}
}

View file

@ -1,19 +1,31 @@
#if DEBUG
using System.Data;
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Notifications.Blobs
{
internal class JunkNotification
internal class JunkNotification: BaseNotification
{
private const string TYPE = "junk";
internal class Handler : NotificationHandler<JunkNotification>
{
public override string NotificationType => "junk";
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "Junk")};
}
}
protected override void FillViewModel(JunkNotification notification, NotificationViewModel vm)
{
vm.Body = $"All your junk r belong to us!";
}
}
public override string Identifier => NotificationType;
public override string NotificationType => TYPE;
}
}
#endif

View file

@ -3,11 +3,20 @@ using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications.Blobs
{
internal class NewVersionNotification
internal class NewVersionNotification:BaseNotification
{
private const string TYPE = "newversion";
internal class Handler : NotificationHandler<NewVersionNotification>
{
public override string NotificationType => "newversion";
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "New version")};
}
}
protected override void FillViewModel(NewVersionNotification notification, NotificationViewModel vm)
{
vm.Body = $"New version {notification.Version} released!";
@ -23,5 +32,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
Version = version;
}
public string Version { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
}
}

View file

@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs
{
public class PayoutNotification
public class PayoutNotification : BaseNotification
{
private const string TYPE = "payout";
internal class Handler : NotificationHandler<PayoutNotification>
{
private readonly LinkGenerator _linkGenerator;
@ -18,18 +20,30 @@ namespace BTCPayServer.Services.Notifications.Blobs
_linkGenerator = linkGenerator;
_options = options;
}
public override string NotificationType => "payout";
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "Payouts")};
}
}
protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm)
{
vm.Body = "A new payout is awaiting for approval";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts),
"Wallets",
new { walletId = new WalletId(notification.StoreId, notification.PaymentMethod) }, _options.RootPath);
new {walletId = new WalletId(notification.StoreId, notification.PaymentMethod)}, _options.RootPath);
}
}
public string PayoutId { get; set; }
public string StoreId { get; set; }
public string PaymentMethod { get; set; }
public string Currency { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
}
}

View file

@ -1,6 +1,5 @@
using System;
using BTCPayServer.Contracts;
using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications
{
@ -9,6 +8,8 @@ namespace BTCPayServer.Services.Notifications
{
public abstract string NotificationType { get; }
Type INotificationHandler.NotificationBlobType => typeof(TNotification);
public abstract (string identifier, string name)[] Meta { get; }
void INotificationHandler.FillViewModel(object notification, NotificationViewModel vm)
{
FillViewModel((TNotification)notification, vm);

View file

@ -21,7 +21,6 @@ namespace BTCPayServer.Services.Notifications
private readonly IMemoryCache _memoryCache;
private readonly EventAggregator _eventAggregator;
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
private readonly Dictionary<Type, INotificationHandler> _handlersByBlobType;
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager,
IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers, EventAggregator eventAggregator)
@ -31,7 +30,6 @@ namespace BTCPayServer.Services.Notifications
_memoryCache = memoryCache;
_eventAggregator = eventAggregator;
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
_handlersByBlobType = handlers.ToDictionary(h => h.NotificationBlobType);
}
private const int _cacheExpiryMs = 5000;
@ -118,12 +116,5 @@ namespace BTCPayServer.Services.Notifications
return h;
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
}
public INotificationHandler GetHandler(Type blobType)
{
if (_handlersByBlobType.TryGetValue(blobType, out var h))
return h;
throw new InvalidOperationException($"No INotificationHandler found for {blobType.Name}");
}
}
}

View file

@ -18,6 +18,16 @@ namespace BTCPayServer.Services.Notifications
}
public string StoreId { get; }
}
public class UserScope : NotificationScope
{
public UserScope(string userId)
{
if (userId == null)
throw new ArgumentNullException(nameof(userId));
UserId = userId;
}
public string UserId { get; }
}
public interface NotificationScope
{

View file

@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Contracts;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications
@ -24,13 +27,13 @@ namespace BTCPayServer.Services.Notifications
_notificationManager = notificationManager;
}
public async Task SendNotification(NotificationScope scope, object notification)
public async Task SendNotification(NotificationScope scope, BaseNotification notification)
{
if (scope == null)
throw new ArgumentNullException(nameof(scope));
if (notification == null)
throw new ArgumentNullException(nameof(notification));
var users = await GetUsers(scope);
var users = await GetUsers(scope, notification.Identifier);
using (var db = _contextFactory.CreateContext())
{
foreach (var uid in users)
@ -41,7 +44,7 @@ namespace BTCPayServer.Services.Notifications
Id = Guid.NewGuid().ToString(),
Created = DateTimeOffset.UtcNow,
ApplicationUserId = uid,
NotificationType = _notificationManager.GetHandler(notification.GetType()).NotificationType,
NotificationType = notification.NotificationType,
Blob = ZipUtils.Zip(obj),
Seen = false
};
@ -55,22 +58,47 @@ namespace BTCPayServer.Services.Notifications
}
}
private async Task<string[]> GetUsers(NotificationScope scope)
private async Task<string[]> GetUsers(NotificationScope scope, string notificationIdentifier)
{
if (scope is AdminScope)
await using var ctx = _contextFactory.CreateContext();
var split = notificationIdentifier.Split('_', StringSplitOptions.None);
var terms = new List<string>();
foreach (var t in split)
{
var admins = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
return admins.Select(a => a.Id).ToArray();
terms.Add(terms.Any() ? $"{terms.Last().TrimEnd(';')}_{t};" : $"{t};");
}
if (scope is StoreScope s)
IQueryable<ApplicationUser> query;
switch (scope)
{
using var ctx = _contextFactory.CreateContext();
return ctx.UserStore
.Where(u => u.StoreDataId == s.StoreId)
.Select(u => u.ApplicationUserId)
.ToArray();
case AdminScope _:
{
query = _userManager.GetUsersInRoleAsync(Roles.ServerAdmin).Result.AsQueryable();
break;
}
case StoreScope s:
query = ctx.UserStore
.Include(store => store.ApplicationUser)
.Where(u => u.StoreDataId == s.StoreId)
.Select(u => u.ApplicationUser);
break;
case UserScope userScope:
query = ctx.Users
.Where(user => user.Id == userScope.UserId);
break;
default:
throw new NotSupportedException("Notification scope not supported");
}
throw new NotSupportedException("Notification scope not supported");
query = query.Where(store => store.DisabledNotifications != "all");
foreach (string term in terms)
{
query = query.Where(user => user.DisabledNotifications == null || !user.DisabledNotifications.Contains(term));
}
return query.Select(user => user.Id).ToArray();
}
}
}

View file

@ -25,7 +25,9 @@ namespace BTCPayServer.Services
[Display(Name = "Allow non-admins to import their hot wallets to the node wallet")]
public bool AllowHotWalletRPCImportForAll { get; set; }
[Display(Name = "Check releases on GitHub and alert when new BTCPayServer version is available")]
public bool CheckForNewVersions { get; set; }
public bool CheckForNewVersions { get; set; }
[Display(Name = "Disable notifications automatically showing (no websockets)")]
public bool DisableInstantNotifications { get; set; }
[Display(Name = "Display app on website root")]
public string RootAppId { get; set; }

View file

@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications
}
}

View file

@ -0,0 +1,45 @@
@using BTCPayServer.Contracts
@model BTCPayServer.Controllers.ManageController.NotificationSettingsViewModel
@inject IEnumerable<INotificationHandler> NotificationHandlers
@{
ViewData.SetActivePageAndTitle(ManageNavPages.Notifications, "Notification preferences");
}
<partial name="_StatusMessage"/>
<form method="post" asp-action="NotificationSettings">
@if (Model.All)
{
<div>
All notifications are disabled.
<button type="submit" class="btn btn-primary" name="command" value="enable-all">Enable</button>
</div>
}
else
{
<div class="form-group">
<label> Do not receive notifications for</label>
<div class="card ">
<ul class="list-group list-group-flush">
@for (var index = 0; index < Model.DisabledNotifications.Count; index++)
{
var item = Model.DisabledNotifications[index];
<li class="list-group-item">
<input type="hidden" asp-for="DisabledNotifications[index].Value"/>
<input type="checkbox" asp-for="DisabledNotifications[index].Selected" class="form-check-inline"/>
<label class="mb-0 cursor-pointer" asp-for="DisabledNotifications[index].Selected">
@item.Text
</label>
</li>
}
</ul>
</div>
</div>
<div>
<button type="submit" class="btn btn-secondary" name="command" value="disable-all">Disable all</button>
<button type="submit" class="btn btn-primary" name="command" value="update">Save</button>
</div>
}
</form>

View file

@ -6,5 +6,6 @@
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
<a id="@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-action="NotificationSettings">Notifications</a>
</div>

View file

@ -42,6 +42,11 @@
<label asp-for="AllowHotWalletRPCImportForAll" class="form-check-label"></label>
<span asp-validation-for="AllowHotWalletRPCImportForAll" class="text-danger"></span>
</div>
<div class="form-check">
<input asp-for="DisableInstantNotifications" type="checkbox" class="form-check-input"/>
<label asp-for="DisableInstantNotifications" class="form-check-label"></label>
<span asp-validation-for="DisableInstantNotifications" class="text-danger"></span>
</div>
@if (ViewBag.UpdateUrlPresent)
{
<div class="form-check">