feat(notifications): support dismissing notifications in PostIt
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
b90f15333d
commit
cb8cf9406d
12 changed files with 389 additions and 181 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -15,10 +15,24 @@
|
||||||
avec lecture/pause, libération du lecteur à la fermeture et accès au
|
avec lecture/pause, libération du lecteur à la fermeture et accès au
|
||||||
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
||||||
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
|
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
|
||||||
|
* [Yavsc.Api] Contrôleur `DismissClicksApiController` (déplacé depuis
|
||||||
|
Yavsc.Org) exposant des DTO `DismissedNotification` :
|
||||||
|
`GET api/v1/dismiss`, `POST api/v1/dismiss/click/{notificationId}`
|
||||||
|
(idempotent, 404 si la notification n'existe pas) et
|
||||||
|
`DELETE api/v1/dismiss/click/{notificationId}` ; authentification requise.
|
||||||
|
* [Yavsc.Api.Client] `NotificationsApiClient.DismissAsync`,
|
||||||
|
`GetDismissedAsync` et `RestoreAsync`.
|
||||||
|
* [PostIt] Bouton de fermeture sur chaque notification de l'accueil
|
||||||
|
qui la démet auprès de Yavsc.Api pour un utilisateur connecté, ou la
|
||||||
|
masque localement sans appel API pour un visiteur non connecté
|
||||||
|
(jusqu'au prochain rechargement des notifications).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
|
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
|
||||||
|
* [Yavsc.Org] L'API `api/v1/dimiss` est supprimée ; le site web démet ses
|
||||||
|
notifications via `POST /Home/DismissNotification/{id}` (base pour un
|
||||||
|
utilisateur connecté, session pour un visiteur anonyme).
|
||||||
|
|
||||||
## [1.0.8-rc25] - unstable
|
## [1.0.8-rc25] - unstable
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,22 @@ public class HomePageNotificationsTests
|
||||||
session.Refresh();
|
session.Refresh();
|
||||||
Assert.False(transport.WasAnonymous);
|
Assert.False(transport.WasAnonymous);
|
||||||
Assert.EndsWith("/notifications/me", transport.LastPath);
|
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||||
|
|
||||||
|
var note = Note(3, "Dismiss me");
|
||||||
|
vm.Notifications.Add(note);
|
||||||
|
Assert.True(vm.DismissNotificationCommand.CanExecute(note));
|
||||||
|
transport.DismissResponse = Task.FromException(new HttpRequestException("Server unavailable"));
|
||||||
|
await vm.DismissNotificationCommand.ExecuteAsync(note);
|
||||||
|
Assert.Contains(note, vm.Notifications);
|
||||||
|
Assert.Contains("Server unavailable", vm.NotificationsError);
|
||||||
|
|
||||||
|
transport.DismissResponse = Task.CompletedTask;
|
||||||
|
await vm.DismissNotificationCommand.ExecuteAsync(note);
|
||||||
|
Assert.Equal(HttpMethod.Post, transport.LastMethod);
|
||||||
|
Assert.Equal("https://business.example/api/v1/dismiss/click/3", transport.LastPath);
|
||||||
|
Assert.DoesNotContain(note, vm.Notifications);
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
|
||||||
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
||||||
transport.Response = privateResponse.Task;
|
transport.Response = privateResponse.Task;
|
||||||
var privateLoad = vm.RefreshNotificationsAsync();
|
var privateLoad = vm.RefreshNotificationsAsync();
|
||||||
|
|
@ -112,6 +128,42 @@ public class HomePageNotificationsTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_dismiss_removes_the_notification_without_an_api_call()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
vm.Notifications.Add(Note(1, "Keep"));
|
||||||
|
vm.Notifications.Add(Note(2, "Dismiss me"));
|
||||||
|
|
||||||
|
Assert.True(vm.DismissNotificationCommand.CanExecute(vm.Notifications[1]));
|
||||||
|
Assert.False(vm.DismissNotificationCommand.CanExecute(null));
|
||||||
|
|
||||||
|
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[1]);
|
||||||
|
|
||||||
|
Assert.Null(transport.LastMethod);
|
||||||
|
Assert.Null(transport.LastPath);
|
||||||
|
Assert.Equal(1, Assert.Single(vm.Notifications).Id);
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_dismiss_of_the_last_notification_updates_the_empty_state()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(3, "Public") });
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
Assert.False(vm.HasNoNotifications);
|
||||||
|
|
||||||
|
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
|
||||||
|
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.True(vm.HasNoNotifications);
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
Assert.Null(transport.LastMethod);
|
||||||
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
||||||
{
|
{
|
||||||
|
|
@ -138,6 +190,12 @@ public class HomePageNotificationsTests
|
||||||
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||||
b => b.Command == vm.RefreshNotificationsCommand);
|
b => b.Command == vm.RefreshNotificationsCommand);
|
||||||
Assert.True(refresh.IsEnabled);
|
Assert.True(refresh.IsEnabled);
|
||||||
|
var dismiss = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||||
|
b => b.Command == vm.DismissNotificationCommand);
|
||||||
|
Assert.Same(vm.Notifications[0], dismiss.CommandParameter);
|
||||||
|
Assert.Equal("Close", dismiss.Content);
|
||||||
|
Assert.True(dismiss.IsVisible);
|
||||||
|
Assert.True(dismiss.IsEnabled);
|
||||||
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
||||||
s => s.Extent.Height > s.Viewport.Height);
|
s => s.Extent.Height > s.Viewport.Height);
|
||||||
|
|
||||||
|
|
@ -145,6 +203,10 @@ public class HomePageNotificationsTests
|
||||||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
||||||
await nav.PopAsync();
|
await nav.PopAsync();
|
||||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
|
||||||
|
window.UpdateLayout();
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.Null(transport.LastMethod);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -173,10 +235,29 @@ public class HomePageNotificationsTests
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
LastMethod = method;
|
||||||
|
if (method == HttpMethod.Post)
|
||||||
|
{
|
||||||
|
LastPath = path;
|
||||||
|
WasAnonymous = false;
|
||||||
|
return DismissAsync<T>(path);
|
||||||
|
}
|
||||||
Assert.Equal(HttpMethod.Get, method);
|
Assert.Equal(HttpMethod.Get, method);
|
||||||
return ReadAsync<T>(path, false);
|
return ReadAsync<T>(path, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<T> DismissAsync<T>(string path)
|
||||||
|
{
|
||||||
|
await DismissResponse;
|
||||||
|
return (T)(object)new DismissedNotification
|
||||||
|
{
|
||||||
|
NotificationId = long.Parse(path[(path.LastIndexOf('/') + 1)..])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpMethod? LastMethod { get; private set; }
|
||||||
|
public Task DismissResponse { get; set; } = Task.CompletedTask;
|
||||||
|
|
||||||
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
||||||
{
|
{
|
||||||
LastPath = path;
|
LastPath = path;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
@ -59,6 +60,9 @@ public class HomePageViewModel : ViewModelBase
|
||||||
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||||
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||||
() => _notificationsClient is not null);
|
() => _notificationsClient is not null);
|
||||||
|
DismissNotificationCommand = new AsyncRelayCommand<Notification?>(DismissNotificationAsync,
|
||||||
|
n => n is not null,
|
||||||
|
AsyncRelayCommandOptions.AllowConcurrentExecutions);
|
||||||
|
|
||||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||||
|
|
@ -86,6 +90,31 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenMyFiles { get; }
|
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||||
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
|
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
|
||||||
|
public IAsyncRelayCommand<Notification?> DismissNotificationCommand { get; }
|
||||||
|
|
||||||
|
public async Task DismissNotificationAsync(Notification? notification)
|
||||||
|
{
|
||||||
|
if (notification is null)
|
||||||
|
return;
|
||||||
|
NotificationsError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (SessionStatus.IsLoggedIn)
|
||||||
|
{
|
||||||
|
if (_notificationsClient is null)
|
||||||
|
throw new InvalidOperationException("Notifications client is not available.");
|
||||||
|
await _notificationsClient.DismissAsync(notification.Id);
|
||||||
|
}
|
||||||
|
var shown = Notifications.FirstOrDefault(n => n.Id == notification.Id);
|
||||||
|
if (shown is not null)
|
||||||
|
Notifications.Remove(shown);
|
||||||
|
OnPropertyChanged(nameof(HasNoNotifications));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
NotificationsError = $"Impossible de masquer la notification : {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RefreshNotificationsAsync()
|
public async Task RefreshNotificationsAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -134,9 +163,15 @@ public class HomePageViewModel : ViewModelBase
|
||||||
|
|
||||||
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
|
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
|
||||||
if (Dispatcher.UIThread.CheckAccess())
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
_ = RefreshNotificationsAsync();
|
OnLoginChanged();
|
||||||
else
|
else
|
||||||
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
|
Dispatcher.UIThread.Post(OnLoginChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoginChanged()
|
||||||
|
{
|
||||||
|
DismissNotificationCommand.NotifyCanExecuteChanged();
|
||||||
|
_ = RefreshNotificationsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,15 @@
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||||
|
Content="{Binding click_action, TargetNullValue=Fermer}"
|
||||||
|
ToolTip.Tip="Masquer cette notification"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:HomePageViewModel)DataContext).DismissNotificationCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ namespace Yavsc
|
||||||
public const string ResourceUsageAdminRoute = "admin";
|
public const string ResourceUsageAdminRoute = "admin";
|
||||||
public const string NotificationsPath = "notifications";
|
public const string NotificationsPath = "notifications";
|
||||||
public const string NotificationsCurrentUserRoute = "me";
|
public const string NotificationsCurrentUserRoute = "me";
|
||||||
|
public const string NotificationsDismissPath = "dismiss";
|
||||||
|
public const string NotificationsDismissClickRoute = "click";
|
||||||
public const string ResourceUsageDefaultCurrency = "EUR";
|
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||||
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||||
public const string ResourceUsageAdminDisplayName = "administrator";
|
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||||
|
|
|
||||||
11
src/Yavsc.Abstract/Messaging/DismissedNotification.cs
Normal file
11
src/Yavsc.Abstract/Messaging/DismissedNotification.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Yavsc.Abstract.Models.Messaging
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire format of a notification dismissed by the current user.
|
||||||
|
/// The user is always implied by the authenticated caller.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DismissedNotification
|
||||||
|
{
|
||||||
|
public long NotificationId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@ using Yavsc.Abstract.Models.Messaging;
|
||||||
namespace Yavsc.Api.Client;
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
|
/// Thin HTTP client for the notifications exposed to PostIt on the home page,
|
||||||
|
/// and for their dismissal by the current user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NotificationsApiClient
|
public sealed class NotificationsApiClient
|
||||||
{
|
{
|
||||||
|
|
@ -48,6 +49,33 @@ public sealed class NotificationsApiClient
|
||||||
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
|
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
|
||||||
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
|
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
|
||||||
|
|
||||||
|
public Task<List<DismissedNotification>> GetDismissedAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<List<DismissedNotification>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute(Constants.NotificationsDismissPath),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DismissedNotification> DismissAsync(long notificationId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<DismissedNotification>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
Absolute(DismissClickPath(notificationId)),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RestoreAsync(long notificationId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
Absolute(DismissClickPath(notificationId)),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DismissClickPath(long notificationId)
|
||||||
|
=> $"{Constants.NotificationsDismissPath}/{Constants.NotificationsDismissClickRoute}/{notificationId}";
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Messaging;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.ApiControllers.Administration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifications dismissed by the authenticated user.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route(Constants.APIPrefix + "/" + Constants.NotificationsDismissPath)]
|
||||||
|
public sealed class DismissClicksApiController : Controller
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
|
||||||
|
public DismissClicksApiController(ApplicationDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<DismissedNotification>>> GetDismissed()
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var dismissed = await _dbContext.DismissClicked
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(d => d.UserId == userId)
|
||||||
|
.OrderBy(d => d.NotificationId)
|
||||||
|
.Select(d => new DismissedNotification { NotificationId = d.NotificationId })
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(dismissed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dismisses a notification for the current user. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost(Constants.NotificationsDismissClickRoute + "/{notificationId:long}")]
|
||||||
|
public async Task<ActionResult<DismissedNotification>> Click(long notificationId)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
if (!await _dbContext.Notification.AnyAsync(n => n.Id == notificationId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var exists = await _dbContext.DismissClicked
|
||||||
|
.AnyAsync(d => d.UserId == userId && d.NotificationId == notificationId);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
_dbContext.DismissClicked.Add(new DismissClicked { UserId = userId, NotificationId = notificationId });
|
||||||
|
await _dbContext.SaveChangesAsync(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new DismissedNotification { NotificationId = notificationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores a previously dismissed notification for the current user.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete(Constants.NotificationsDismissClickRoute + "/{notificationId:long}")]
|
||||||
|
public async Task<IActionResult> Restore(long notificationId)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var dismissed = await _dbContext.DismissClicked
|
||||||
|
.SingleOrDefaultAsync(d => d.UserId == userId && d.NotificationId == notificationId);
|
||||||
|
if (dismissed is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
_dbContext.DismissClicked.Remove(dismissed);
|
||||||
|
await _dbContext.SaveChangesAsync(userId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Yavsc.Models;
|
|
||||||
using Yavsc.Models.Messaging;
|
|
||||||
using Yavsc.Server.Helpers;
|
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
|
||||||
{
|
|
||||||
[Produces("application/json")]
|
|
||||||
[Route(Constants.APIPrefix + "/v1/dimiss")]
|
|
||||||
public class DimissClicksApiController : Controller
|
|
||||||
{
|
|
||||||
private readonly ApplicationDbContext _context;
|
|
||||||
|
|
||||||
public DimissClicksApiController(ApplicationDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: api/DimissClicksApi
|
|
||||||
[HttpGet]
|
|
||||||
public IEnumerable<DismissClicked> GetDismissClicked()
|
|
||||||
{
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
return _context.DismissClicked.Where(d=>d.UserId == uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("click/{noteid}"),AllowAnonymous]
|
|
||||||
public async Task<IActionResult> Click(long noteid )
|
|
||||||
{
|
|
||||||
if (User.IsSignedIn())
|
|
||||||
return await PostDismissClicked(new DismissClicked { NotificationId= noteid, UserId = User.GetUserId()});
|
|
||||||
await HttpContext.Session.LoadAsync();
|
|
||||||
var clicked = HttpContext.Session.GetString("clicked");
|
|
||||||
if (clicked == null) {
|
|
||||||
HttpContext.Session.SetString("clicked",noteid.ToString());
|
|
||||||
} else HttpContext.Session.SetString("clicked",$"{clicked}:{noteid}");
|
|
||||||
await HttpContext.Session.CommitAsync();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
// GET: api/DimissClicksApi/5
|
|
||||||
[HttpGet("{id}", Name = "GetDismissClicked")]
|
|
||||||
public async Task<IActionResult> GetDismissClicked([FromRoute] string id)
|
|
||||||
{
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (uid != id) return new ChallengeResult();
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return BadRequest(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
DismissClicked DismissClicked = await _context.DismissClicked.SingleAsync(m => m.UserId == id);
|
|
||||||
|
|
||||||
if (DismissClicked == null)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(DismissClicked);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT: api/DimissClicksApi/5
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
public async Task<IActionResult> PutDismissClicked([FromRoute] string id, [FromBody] DismissClicked DismissClicked)
|
|
||||||
{
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (uid != id || uid != DismissClicked.UserId) return new ChallengeResult();
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return BadRequest(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id != DismissClicked.UserId)
|
|
||||||
{
|
|
||||||
return BadRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
_context.Entry(DismissClicked).State = EntityState.Modified;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _context.SaveChangesAsync(User.GetUserId());
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
if (!DismissClickedExists(id))
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StatusCodeResult(StatusCodes.Status204NoContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: api/DimissClicksApi
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> PostDismissClicked([FromBody] DismissClicked DismissClicked)
|
|
||||||
{
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (uid != DismissClicked.UserId) return new ChallengeResult();
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return BadRequest(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
_context.DismissClicked.Add(DismissClicked);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _context.SaveChangesAsync(User.GetUserId());
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
if (DismissClickedExists(DismissClicked.UserId))
|
|
||||||
{
|
|
||||||
return new StatusCodeResult(StatusCodes.Status409Conflict);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreatedAtRoute("GetDismissClicked", new { id = DismissClicked.UserId }, DismissClicked);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: api/DimissClicksApi/5
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
public async Task<IActionResult> DeleteDismissClicked([FromRoute] string id)
|
|
||||||
{
|
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (!User.IsInRole("Administrator"))
|
|
||||||
if (uid != id) return new ChallengeResult();
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return BadRequest(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
DismissClicked DismissClicked = await _context.DismissClicked.SingleAsync(m => m.UserId == id);
|
|
||||||
if (DismissClicked == null)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
_context.DismissClicked.Remove(DismissClicked);
|
|
||||||
await _context.SaveChangesAsync(User.GetUserId());
|
|
||||||
|
|
||||||
return Ok(DismissClicked);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
_context.Dispose();
|
|
||||||
}
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool DismissClickedExists(string id)
|
|
||||||
{
|
|
||||||
return _context.DismissClicked.Count(e => e.UserId == id) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -95,6 +95,36 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
return View(toShow);
|
return View(toShow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Web-site dismissal of a notification: persisted for signed-in users,
|
||||||
|
/// kept in the session for anonymous visitors.
|
||||||
|
/// API clients use <c>POST api/v1/dismiss/click/{id}</c> on Yavsc.Api.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> DismissNotification(long id)
|
||||||
|
{
|
||||||
|
if (!await _dbContext.Notification.AnyAsync(n => n.Id == id))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (uid != null)
|
||||||
|
{
|
||||||
|
if (!await _dbContext.DismissClicked.AnyAsync(d => d.UserId == uid && d.NotificationId == id))
|
||||||
|
{
|
||||||
|
_dbContext.DismissClicked.Add(new Yavsc.Models.Messaging.DismissClicked { UserId = uid, NotificationId = id });
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
await HttpContext.Session.LoadAsync();
|
||||||
|
var clicked = HttpContext.Session.GetString("clicked");
|
||||||
|
HttpContext.Session.SetString("clicked", clicked == null ? id.ToString() : $"{clicked}:{id}");
|
||||||
|
await HttpContext.Session.CommitAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> About()
|
public async Task<IActionResult> About()
|
||||||
{
|
{
|
||||||
return View("About");
|
return View("About");
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
var notifClick =
|
var notifClick =
|
||||||
function(nid) {
|
function(nid) {
|
||||||
if (nid > 0) {
|
if (nid > 0) {
|
||||||
$.get({
|
$.post({
|
||||||
url: '/api/v1/dimiss/click/' + nid,
|
url: '/Home/DismissNotification/' + nid,
|
||||||
success: $('div[data-nid='+nid+']').remove()
|
success: $('div[data-nid='+nid+']').remove()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
src/test/Yavsc.Api.Tests/DismissClicksApiControllerTests.cs
Normal file
98
src/test/Yavsc.Api.Tests/DismissClicksApiControllerTests.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class DismissClicksApiControllerTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
private HttpClient NewClient(string? subject = "alice")
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
})
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(fixture.BaseAddress)
|
||||||
|
};
|
||||||
|
if (subject != null)
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, "api"));
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> SeedNotificationAsync()
|
||||||
|
{
|
||||||
|
fixture.ResetAndSeedActivityGraph();
|
||||||
|
using var scope = fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var note = new Notification { title = "Info", body = "Corps", click_action = "Fermer" };
|
||||||
|
db.Notification.Add(note);
|
||||||
|
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
return note.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Click_dismisses_once_and_hides_the_notification_for_the_user()
|
||||||
|
{
|
||||||
|
var id = await SeedNotificationAsync();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
using var http = NewClient();
|
||||||
|
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var response = await http.PostAsync($"/api/v1/dismiss/click/{id}", null, ct);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DismissedNotification>(ct);
|
||||||
|
Assert.Equal(id, dto!.NotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dismissed = await http.GetFromJsonAsync<List<DismissedNotification>>("/api/v1/dismiss", ct);
|
||||||
|
Assert.Equal(id, Assert.Single(dismissed!).NotificationId);
|
||||||
|
|
||||||
|
var visible = await http.GetFromJsonAsync<List<Notification>>("/api/v1/notifications/me", ct);
|
||||||
|
Assert.DoesNotContain(visible!, n => n.Id == id);
|
||||||
|
|
||||||
|
using (var scope = fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
Assert.Equal(1, await db.DismissClicked.CountAsync(d => d.UserId == "alice" && d.NotificationId == id, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
var restore = await http.DeleteAsync($"/api/v1/dismiss/click/{id}", ct);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, restore.StatusCode);
|
||||||
|
visible = await http.GetFromJsonAsync<List<Notification>>("/api/v1/notifications/me", ct);
|
||||||
|
Assert.Contains(visible!, n => n.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Click_on_an_unknown_notification_returns_not_found()
|
||||||
|
{
|
||||||
|
var id = await SeedNotificationAsync();
|
||||||
|
using var http = NewClient();
|
||||||
|
|
||||||
|
var response = await http.PostAsync($"/api/v1/dismiss/click/{id + 1000}", null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Click_requires_authentication()
|
||||||
|
{
|
||||||
|
var id = await SeedNotificationAsync();
|
||||||
|
using var http = NewClient(null);
|
||||||
|
|
||||||
|
var response = await http.PostAsync($"/api/v1/dismiss/click/{id}", null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue