Greenfield: Add store id for notifications (#6093)

* Rename filter to storeid for consistency with other filters

* Greenfield: Add storeId to notification

* Cleanups

* Greenfield: Allow filtering notifications by store id
This commit is contained in:
d11n
2024-07-10 17:12:22 +02:00
committed by GitHub
parent d73e26e0c4
commit a4a1fa0746
10 changed files with 100 additions and 59 deletions

View File

@@ -9,7 +9,7 @@ namespace BTCPayServer.Client;
public partial class BTCPayServerClient public partial class BTCPayServerClient
{ {
public virtual async Task<IEnumerable<NotificationData>> GetNotifications(bool? seen = null, int? skip = null, public virtual async Task<IEnumerable<NotificationData>> GetNotifications(bool? seen = null, int? skip = null,
int? take = null, CancellationToken token = default) int? take = null, string[] storeId = null, CancellationToken token = default)
{ {
var queryPayload = new Dictionary<string, object>(); var queryPayload = new Dictionary<string, object>();
if (seen != null) if (seen != null)
@@ -18,6 +18,8 @@ public partial class BTCPayServerClient
queryPayload.Add(nameof(skip), skip); queryPayload.Add(nameof(skip), skip);
if (take != null) if (take != null)
queryPayload.Add(nameof(take), take); queryPayload.Add(nameof(take), take);
if (storeId != null)
queryPayload.Add(nameof(storeId), storeId);
return await SendHttpRequest<IEnumerable<NotificationData>>("api/v1/users/me/notifications", queryPayload, HttpMethod.Get, token); return await SendHttpRequest<IEnumerable<NotificationData>>("api/v1/users/me/notifications", queryPayload, HttpMethod.Get, token);
} }

View File

@@ -9,6 +9,7 @@ namespace BTCPayServer.Client.Models
public string Identifier { get; set; } public string Identifier { get; set; }
public string Type { get; set; } public string Type { get; set; }
public string Body { get; set; } public string Body { get; set; }
public string StoreId { get; set; }
public bool Seen { get; set; } public bool Seen { get; set; }
public Uri Link { get; set; } public Uri Link { get; set; }

View File

@@ -1,7 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -13,7 +13,6 @@ using BTCPayServer.Events;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424; using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors; using BTCPayServer.PayoutProcessors;
using BTCPayServer.Services; using BTCPayServer.Services;
@@ -30,7 +29,6 @@ using Newtonsoft.Json.Linq;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using Xunit.Sdk; using Xunit.Sdk;
using static Org.BouncyCastle.Math.EC.ECCurve;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
@@ -2915,14 +2913,18 @@ namespace BTCPayServer.Tests
await tester.PayTester.GetService<NotificationSender>() await tester.PayTester.GetService<NotificationSender>()
.SendNotification(new UserScope(user.UserId), new NewVersionNotification()); .SendNotification(new UserScope(user.UserId), new NewVersionNotification());
Assert.Single(await viewOnlyClient.GetNotifications()); var notifications = (await viewOnlyClient.GetNotifications()).ToList();
Assert.Single(notifications);
Assert.Single(await viewOnlyClient.GetNotifications(false)); Assert.Single(await viewOnlyClient.GetNotifications(false));
Assert.Empty(await viewOnlyClient.GetNotifications(true)); Assert.Empty(await viewOnlyClient.GetNotifications(true));
var notification = notifications.First();
Assert.Null(notification.StoreId);
Assert.Single(await client.GetNotifications()); Assert.Single(await client.GetNotifications());
Assert.Single(await client.GetNotifications(false)); Assert.Single(await client.GetNotifications(false));
Assert.Empty(await client.GetNotifications(true)); Assert.Empty(await client.GetNotifications(true));
var notification = (await client.GetNotifications()).First(); notification = (await client.GetNotifications()).First();
notification = await client.GetNotification(notification.Id); notification = await client.GetNotification(notification.Id);
Assert.False(notification.Seen); Assert.False(notification.Seen);
await AssertHttpError(403, async () => await AssertHttpError(403, async () =>
@@ -2940,6 +2942,41 @@ namespace BTCPayServer.Tests
Assert.Empty(await viewOnlyClient.GetNotifications(true)); Assert.Empty(await viewOnlyClient.GetNotifications(true));
Assert.Empty(await viewOnlyClient.GetNotifications(false)); Assert.Empty(await viewOnlyClient.GetNotifications(false));
// Store association
var unrestricted = await user.CreateClient(Policies.Unrestricted);
var store1 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store A" });
await tester.PayTester.GetService<NotificationSender>()
.SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{
UserId = user.UserId,
UserEmail = user.Email,
StoreId = store1.Id,
StoreName = store1.Name
});
notifications = (await client.GetNotifications()).ToList();
Assert.Single(notifications);
notification = notifications.First();
Assert.Equal(store1.Id, notification.StoreId);
Assert.Equal($"User {user.Email} accepted the invite to {store1.Name}.", notification.Body);
var store2 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store B" });
await tester.PayTester.GetService<NotificationSender>()
.SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{
UserId = user.UserId,
UserEmail = user.Email,
StoreId = store2.Id,
StoreName = store2.Name
});
notifications = (await client.GetNotifications(storeId: [store2.Id])).ToList();
Assert.Single(notifications);
notification = notifications.First();
Assert.Equal(store2.Id, notification.StoreId);
Assert.Equal($"User {user.Email} accepted the invite to {store2.Name}.", notification.Body);
Assert.Equal(2, (await client.GetNotifications(storeId: [store1.Id, store2.Id])).Count());
Assert.Equal(2, (await client.GetNotifications()).Count());
// Settings // Settings
var settings = await client.GetNotificationSettings(); var settings = await client.GetNotificationSettings();
Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled); Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);

View File

@@ -1,3 +1,4 @@
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -36,71 +37,58 @@ namespace BTCPayServer.Controllers.Greenfield
_notificationHandlers = notificationHandlers; _notificationHandlers = notificationHandlers;
} }
[Authorize(Policy = Policies.CanViewNotificationsForUser, [Authorize(Policy = Policies.CanViewNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/me/notifications")] [HttpGet("~/api/v1/users/me/notifications")]
public async Task<IActionResult> GetNotifications(bool? seen = null, [FromQuery] int? skip = null, [FromQuery] int? take = null) public async Task<IActionResult> GetNotifications(bool? seen = null, [FromQuery] int? skip = null, [FromQuery] int? take = null, [FromQuery] string[]? storeId = null)
{ {
var items = await _notificationManager.GetNotifications(new NotificationsQuery() var items = await _notificationManager.GetNotifications(new NotificationsQuery
{ {
Seen = seen, Seen = seen,
UserId = _userManager.GetUserId(User), UserId = _userManager.GetUserId(User),
Skip = skip, Skip = skip,
Take = take Take = take,
StoreIds = storeId,
}); });
return Ok(items.Items.Select(ToModel)); return Ok(items.Items.Select(ToModel));
} }
[Authorize(Policy = Policies.CanViewNotificationsForUser, [Authorize(Policy = Policies.CanViewNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/me/notifications/{id}")] [HttpGet("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> GetNotification(string id) public async Task<IActionResult> GetNotification(string id)
{ {
var items = await _notificationManager.GetNotifications(new NotificationsQuery() var items = await _notificationManager.GetNotifications(new NotificationsQuery
{ {
Ids = new[] { id }, Ids = [id],
UserId = _userManager.GetUserId(User) UserId = _userManager.GetUserId(User)
}); });
if (items.Count == 0) return items.Count == 0 ? NotificationNotFound() : Ok(ToModel(items.Items.First()));
{
return NotificationNotFound();
}
return Ok(ToModel(items.Items.First()));
} }
[Authorize(Policy = Policies.CanManageNotificationsForUser, [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/users/me/notifications/{id}")] [HttpPut("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> UpdateNotification(string id, UpdateNotification request) public async Task<IActionResult> UpdateNotification(string id, UpdateNotification request)
{ {
var items = await _notificationManager.ToggleSeen( var items = await _notificationManager.ToggleSeen(
new NotificationsQuery() { Ids = new[] { id }, UserId = _userManager.GetUserId(User) }, request.Seen); new NotificationsQuery { Ids = [id], UserId = _userManager.GetUserId(User) }, request.Seen);
if (items.Count == 0) return items.Count == 0 ? NotificationNotFound() : Ok(ToModel(items.First()));
{
return NotificationNotFound();
}
return Ok(ToModel(items.First()));
} }
[Authorize(Policy = Policies.CanManageNotificationsForUser, [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/users/me/notifications/{id}")] [HttpDelete("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> DeleteNotification(string id) public async Task<IActionResult> DeleteNotification(string id)
{ {
await _notificationManager.Remove(new NotificationsQuery() await _notificationManager.Remove(new NotificationsQuery
{ {
Ids = new[] { id }, Ids = [id],
UserId = _userManager.GetUserId(User) UserId = _userManager.GetUserId(User)
}); });
return Ok(); return Ok();
} }
[Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/me/notification-settings")] [HttpGet("~/api/v1/users/me/notification-settings")]
public async Task<IActionResult> GetNotificationSettings() public async Task<IActionResult> GetNotificationSettings()
@@ -132,7 +120,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(model); return Ok(model);
} }
private NotificationData ToModel(NotificationViewModel entity) private static NotificationData ToModel(NotificationViewModel entity)
{ {
return new NotificationData return new NotificationData
{ {
@@ -141,10 +129,12 @@ namespace BTCPayServer.Controllers.Greenfield
Type = entity.Type, Type = entity.Type,
CreatedTime = entity.Created, CreatedTime = entity.Created,
Body = entity.Body, Body = entity.Body,
StoreId = entity.StoreId,
Seen = entity.Seen, Seen = entity.Seen,
Link = string.IsNullOrEmpty(entity.ActionLink) ? null : new Uri(entity.ActionLink) Link = string.IsNullOrEmpty(entity.ActionLink) ? null : new Uri(entity.ActionLink)
}; };
} }
private IActionResult NotificationNotFound() private IActionResult NotificationNotFound()
{ {
return this.CreateAPIError(404, "notification-not-found", "The notification was not found"); return this.CreateAPIError(404, "notification-not-found", "The notification was not found");

View File

@@ -656,10 +656,10 @@ namespace BTCPayServer.Controllers.Greenfield
} }
public override async Task<IEnumerable<NotificationData>> GetNotifications(bool? seen = null, public override async Task<IEnumerable<NotificationData>> GetNotifications(bool? seen = null,
int? skip = null, int? take = null, CancellationToken token = default) int? skip = null, int? take = null, string[] storeId = null, CancellationToken token = default)
{ {
return GetFromActionResult<IEnumerable<NotificationData>>( return GetFromActionResult<IEnumerable<NotificationData>>(
await GetController<GreenfieldNotificationsController>().GetNotifications(seen, skip, take)); await GetController<GreenfieldNotificationsController>().GetNotifications(seen, skip, take, storeId));
} }
public override async Task<NotificationData> GetNotification(string notificationId, public override async Task<NotificationData> GetNotification(string notificationId,

View File

@@ -19,7 +19,6 @@ namespace BTCPayServer.Controllers
[Route("notifications/{action:lowercase=Index}")] [Route("notifications/{action:lowercase=Index}")]
public class UINotificationsController : Controller public class UINotificationsController : Controller
{ {
private readonly ApplicationDbContextFactory _factory;
private readonly StoreRepository _storeRepo; private readonly StoreRepository _storeRepo;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager; private readonly NotificationManager _notificationManager;
@@ -27,13 +26,11 @@ namespace BTCPayServer.Controllers
public UINotificationsController( public UINotificationsController(
StoreRepository storeRepo, StoreRepository storeRepo,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
NotificationManager notificationManager, NotificationManager notificationManager)
ApplicationDbContextFactory factory)
{ {
_storeRepo = storeRepo; _storeRepo = storeRepo;
_userManager = userManager; _userManager = userManager;
_notificationManager = notificationManager; _notificationManager = notificationManager;
_factory = factory;
} }
[HttpGet] [HttpGet]
@@ -49,9 +46,6 @@ namespace BTCPayServer.Controllers
var stores = await _storeRepo.GetStoresByUserId(userId); var stores = await _storeRepo.GetStoresByUserId(userId);
model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList(); model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList();
await using var dbContext = _factory.CreateContext();
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}"; var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
var fs = new SearchString(searchTerm, timezoneOffset); var fs = new SearchString(searchTerm, timezoneOffset);
model.Search = fs; model.Search = fs;
@@ -63,7 +57,7 @@ namespace BTCPayServer.Controllers
UserId = userId, UserId = userId,
SearchText = model.SearchText, SearchText = model.SearchText,
Type = fs.GetFilterArray("type"), Type = fs.GetFilterArray("type"),
Stores = fs.GetFilterArray("store"), StoreIds = fs.GetFilterArray("storeid"),
Seen = model.Status == "Unread" ? false : null Seen = model.Status == "Unread" ? false : null
}); });
model.Items = res.Items; model.Items = res.Items;
@@ -71,14 +65,13 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
} }
[HttpPost] [HttpPost]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)]
public async Task<IActionResult> FlipRead(string id) public async Task<IActionResult> FlipRead(string id)
{ {
if (ValidUserClaim(out var userId)) if (ValidUserClaim(out var userId))
{ {
await _notificationManager.ToggleSeen(new NotificationsQuery() { Ids = new[] { id }, UserId = userId }, null); await _notificationManager.ToggleSeen(new NotificationsQuery { Ids = [id], UserId = userId }, null);
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
@@ -91,9 +84,9 @@ namespace BTCPayServer.Controllers
if (ValidUserClaim(out var userId)) if (ValidUserClaim(out var userId))
{ {
var items = await var items = await
_notificationManager.ToggleSeen(new NotificationsQuery() _notificationManager.ToggleSeen(new NotificationsQuery
{ {
Ids = new[] { id }, Ids = [id],
UserId = userId UserId = userId
}, true); }, true);
@@ -168,7 +161,7 @@ namespace BTCPayServer.Controllers
{ {
return NotFound(); return NotFound();
} }
await _notificationManager.ToggleSeen(new NotificationsQuery() { Seen = false, UserId = userId }, true); await _notificationManager.ToggleSeen(new NotificationsQuery { Seen = false, UserId = userId }, true);
return LocalRedirect(returnUrl); return LocalRedirect(returnUrl);
} }

View File

@@ -54,7 +54,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
} }
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.StoreId = notification?.StoreId; vm.StoreId = notification.StoreId;
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice), vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice),
"UIInvoice", "UIInvoice",
new { invoiceId = notification.InvoiceId }, _options.RootPath); new { invoiceId = notification.InvoiceId }, _options.RootPath);

View File

@@ -151,9 +151,9 @@ namespace BTCPayServer.Services.Notifications
} }
} }
} }
if (query.Stores?.Length > 0) if (query.StoreIds?.Length > 0)
{ {
notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.Stores.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList(); notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.StoreIds.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList();
} }
return notifications; return notifications;
} }
@@ -221,6 +221,6 @@ namespace BTCPayServer.Services.Notifications
public bool? Seen { get; set; } public bool? Seen { get; set; }
public string SearchText { get; set; } public string SearchText { get; set; }
public string[] Type { get; set; } public string[] Type { get; set; }
public string[] Stores { get; set; } public string[] StoreIds { get; set; }
} }
} }

View File

@@ -3,7 +3,7 @@
ViewData["Title"] = "Notifications"; ViewData["Title"] = "Notifications";
string status = ViewBag.Status; string status = ViewBag.Status;
var statusFilterCount = CountArrayFilter("type"); var statusFilterCount = CountArrayFilter("type");
var storesFilterCount = CountArrayFilter("store"); var storesFilterCount = CountArrayFilter("storeid");
} }
@functions @functions
@@ -86,7 +86,7 @@
<button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (storesFilterCount > 0) @if (storesFilterCount > 0)
{ {
<span>@storesFilterCount Store</span> <span>@storesFilterCount Store@(storesFilterCount > 1 ? "s" : "")</span>
} }
else else
{ {
@@ -99,8 +99,8 @@
<a asp-action="Index" <a asp-action="Index"
asp-route-count="@Model.Count" asp-route-count="@Model.Count"
asp-route-status="@Model.Status" asp-route-status="@Model.Status"
asp-route-searchTerm="@Model.Search.Toggle("store", store.Id)" asp-route-searchTerm="@Model.Search.Toggle("storeid", store.Id)"
class="dropdown-item @(HasArrayFilter("store", store.Id) ? "custom-active" : "")"> class="dropdown-item @(HasArrayFilter("storeid", store.Id) ? "custom-active" : "")">
@store.StoreName @store.StoreName
</a> </a>
} }

View File

@@ -36,6 +36,19 @@
"nullable": true, "nullable": true,
"type": "number" "type": "number"
} }
},
{
"name": "storeId",
"in": "query",
"required": false,
"description": "Array of store ids to fetch the notifications for",
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"example": "&storeId=ABCDE&storeId=FGHIJ"
} }
], ],
"description": "View current user's notifications", "description": "View current user's notifications",
@@ -313,6 +326,11 @@
"format": "html", "format": "html",
"description": "The html body of the notifications" "description": "The html body of the notifications"
}, },
"storeId": {
"type": "string",
"nullable": true,
"description": "If related to a store, the store id of the notification"
},
"link": { "link": {
"type": "string", "type": "string",
"format": "uri", "format": "uri",