From 8b4b6a7c53021db2dabd18004e88a204d19bce94 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:23:55 +0100 Subject: [PATCH] Add notifications API for PostIt home page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++ src/Yavsc.Abstract/Constants.cs | 2 + .../NotificationsApiClient.cs | 58 +++++++++++++++++++ .../NotificationsApiController.cs | 50 ++++++++++++++++ .../ResourceUsageApiControllerTests.cs | 44 ++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 src/Yavsc.Api.Client/NotificationsApiClient.cs create mode 100644 src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ad580b9..eaeca5faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` + pour exposer les notifications publiques et ciblées à PostIt sur la + Home page ; les notifications d'administration restent filtrées. + ### Changed ### Fixed diff --git a/src/Yavsc.Abstract/Constants.cs b/src/Yavsc.Abstract/Constants.cs index 0d06582bf..5883fea43 100644 --- a/src/Yavsc.Abstract/Constants.cs +++ b/src/Yavsc.Abstract/Constants.cs @@ -33,6 +33,8 @@ namespace Yavsc public const string ResourceUsagePath = "resource-usage"; public const string ResourceUsageCurrentUserRoute = "me"; public const string ResourceUsageAdminRoute = "admin"; + public const string NotificationsPath = "notifications"; + public const string NotificationsCurrentUserRoute = "me"; public const string ResourceUsageDefaultCurrency = "EUR"; public const string ResourceUsageCurrentUserDisplayName = "current-user"; public const string ResourceUsageAdminDisplayName = "administrator"; diff --git a/src/Yavsc.Api.Client/NotificationsApiClient.cs b/src/Yavsc.Api.Client/NotificationsApiClient.cs new file mode 100644 index 000000000..3e93deb25 --- /dev/null +++ b/src/Yavsc.Api.Client/NotificationsApiClient.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Abstract.Models.Messaging; + +namespace Yavsc.Api.Client; + +/// +/// Thin HTTP client for the notifications exposed to PostIt on the home page. +/// +public sealed class NotificationsApiClient +{ + private const string PathPrefix = Constants.NotificationsPath; + + private readonly IYavscApiClient _api; + private readonly Func _businessBaseAddress; + + public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress) + : this(api, () => businessBaseAddress) + { + } + + public NotificationsApiClient(IYavscApiClient api, Func businessBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress)); + _ = ResolveBusinessBaseAddress(); + } + + public Task> GetCurrentAsync(CancellationToken ct = default) + { + return _api.CallAsync>( + HttpMethod.Get, + Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"), + ct: ct); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + return _api.CallAsync>( + HttpMethod.Get, + Absolute(PathPrefix), + ct: ct); + } + + private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); + + private Uri ResolveBusinessBaseAddress() + { + var raw = _businessBaseAddress(); + if (string.IsNullOrWhiteSpace(raw)) + throw new InvalidOperationException("Business base address is required."); + + return new Uri(raw, UriKind.Absolute); + } +} diff --git a/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs new file mode 100644 index 000000000..7053a5acc --- /dev/null +++ b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Models; +using Yavsc.Server.Helpers; + +namespace Yavsc.ApiControllers.Administration; + +[Produces("application/json")] +[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)] +public sealed class NotificationsApiController : Controller +{ + private readonly ApplicationDbContext _dbContext; + + public NotificationsApiController(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet] + [HttpGet(Constants.NotificationsCurrentUserRoute)] + [AllowAnonymous] + public async Task>> GetCurrentUserNotifications() + { + var currentUserId = User.GetUserId(); + var isAdmin = User.IsInRole(Constants.AdminGroupName); + + var dismissedIds = string.IsNullOrWhiteSpace(currentUserId) + ? new HashSet() + : await _dbContext.DismissClicked + .AsNoTracking() + .Where(d => d.UserId == currentUserId) + .Select(d => d.NotificationId) + .ToHashSetAsync(); + + var notifications = await _dbContext.Notification + .AsNoTracking() + .Where(n => !dismissedIds.Contains(n.Id)) + .Where(n => + string.IsNullOrWhiteSpace(n.Target) + || (n.Target != null && n.Target.StartsWith("pub/")) + || (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}") + || (isAdmin && n.Target == "administration")) + .OrderByDescending(n => n.Id) + .ToListAsync(); + + return Ok(notifications); + } +} diff --git a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs index 33b3f392a..9bb278551 100644 --- a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Yavsc.Abstract.Models.Messaging; using Yavsc.Abstract.Resources; using Yavsc.Api.Test.Fixtures; using Yavsc.Server.Helpers; @@ -115,6 +116,49 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture 0m); } + [Fact] + public async Task GetCurrentUserNotifications_returns_public_and_targeted_notifications() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Notification.RemoveRange(db.Notification); + await db.Notification.AddRangeAsync( + new Notification + { + title = "Public", + body = "Seen by everyone", + click_action = "/public", + Target = null, + }, + new Notification + { + title = "User", + body = "Private to current user", + click_action = "/profile", + Target = "user/telemetry-user", + }, + new Notification + { + title = "Admin", + body = "Restricted to administrators", + click_action = "/admin", + Target = "administration", + }); + await db.SaveChangesAsync(); + + using var http = NewClient(_fixture, "telemetry-user"); + + var response = await http.GetAsync("/api/v1/notifications/me", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notifications = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken); + Assert.NotNull(notifications); + Assert.Contains(notifications!, n => n.title == "Public"); + Assert.Contains(notifications!, n => n.title == "User"); + Assert.DoesNotContain(notifications!, n => n.title == "Admin"); + } + [Fact] public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user() {