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
|
||||
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
||||
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
|
||||
|
||||
* 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -37,8 +37,15 @@
|
|||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"/>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
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;
|
||||
|
||||
/// <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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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