feat(notifications): support dismissing notifications in PostIt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 12:49:52 +01:00
commit cb8cf9406d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
12 changed files with 389 additions and 181 deletions

View file

@ -90,6 +90,22 @@ public class HomePageNotificationsTests
session.Refresh();
Assert.False(transport.WasAnonymous);
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>>();
transport.Response = privateResponse.Task;
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]
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>(),
b => b.Command == vm.RefreshNotificationsCommand);
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>(),
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") });
await nav.PopAsync();
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
{
@ -173,10 +235,29 @@ public class HomePageNotificationsTests
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
LastMethod = method;
if (method == HttpMethod.Post)
{
LastPath = path;
WasAnonymous = false;
return DismissAsync<T>(path);
}
Assert.Equal(HttpMethod.Get, method);
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)
{
LastPath = path;

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
@ -59,6 +60,9 @@ public class HomePageViewModel : ViewModelBase
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
DismissNotificationCommand = new AsyncRelayCommand<Notification?>(DismissNotificationAsync,
n => n is not null,
AsyncRelayCommandOptions.AllowConcurrentExecutions);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -86,6 +90,31 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { 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()
{
@ -134,9 +163,15 @@ public class HomePageViewModel : ViewModelBase
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
OnLoginChanged();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
Dispatcher.UIThread.Post(OnLoginChanged);
}
private void OnLoginChanged()
{
DismissNotificationCommand.NotifyCanExecuteChanged();
_ = RefreshNotificationsAsync();
}
private async Task OpenActivitiesAsync()

View file

@ -37,8 +37,15 @@
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,Auto">
<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"/>
</StackPanel>
</Border>

View file

@ -35,6 +35,8 @@ namespace Yavsc
public const string ResourceUsageAdminRoute = "admin";
public const string NotificationsPath = "notifications";
public const string NotificationsCurrentUserRoute = "me";
public const string NotificationsDismissPath = "dismiss";
public const string NotificationsDismissClickRoute = "click";
public const string ResourceUsageDefaultCurrency = "EUR";
public const string ResourceUsageCurrentUserDisplayName = "current-user";
public const string ResourceUsageAdminDisplayName = "administrator";

View 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; }
}
}

View file

@ -8,7 +8,8 @@ using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Api.Client;
/// <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>
public sealed class NotificationsApiClient
{
@ -48,6 +49,33 @@ public sealed class NotificationsApiClient
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
=> _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 Uri ResolveBusinessBaseAddress()

View file

@ -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();
}
}

View file

@ -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;
}
}
}

View file

@ -95,6 +95,36 @@ namespace Yavsc.Controllers
}
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()
{
return View("About");

View file

@ -2,8 +2,8 @@
var notifClick =
function(nid) {
if (nid > 0) {
$.get({
url: '/api/v1/dimiss/click/' + nid,
$.post({
url: '/Home/DismissNotification/' + nid,
success: $('div[data-nid='+nid+']').remove()
});
}

View 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);
}
}