From 7ee0c3050013bf98160ced4a80c74f5622a00690 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:03:19 +0100 Subject: [PATCH 01/21] Fix authenticated resource usage tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ src/Yavsc.Api/Program.cs | 2 +- src/Yavsc.Blogs/Program.cs | 2 +- .../ResourceUsageApiControllerTests.cs | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a3be3a2..36ad580b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog +## [1.0.8-rc25] - unstable +### Added + +### Changed + +### Fixed + +* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont + désormais enregistrés côté serveur pour les requêtes authentifiées : + le middleware de télémétrie est maintenant branché après + l'authentification, ce qui permet de comptabiliser `ApiCalls` et + `BandwidthMb` au lieu d'enregistrer des zéros. ## [1.0.8-rc24] - unstable diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 3545774ae..be9601d49 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -134,11 +134,11 @@ internal class Program if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); - app.UseMiddleware(); app .UseRouting() .UseAuthentication() .UseAuthorization() + .UseMiddleware() .UseCors("default"); app.MapIdentityApi().RequireAuthorization("ApiScope"); app.MapDefaultControllerRoute(); diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index 21a26e637..6d69c6785 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -98,11 +98,11 @@ internal class Program { if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); - app.UseMiddleware(); app .UseRouting() .UseAuthentication() .UseAuthorization() + .UseMiddleware() .UseCors("default") ; app.Services.GetRequiredService().CreateLogger("Program") diff --git a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs index 8b8347aae..33b3f392a 100644 --- a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs @@ -98,6 +98,23 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture= 3.5m); } + [Fact] + public async Task GetCurrentUserUsage_records_telemetry_values_from_authenticated_requests() + { + using var http = NewClient(_fixture, "telemetry-user"); + + var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.ResourceUsageRecords + .SingleAsync(r => r.UserId == "telemetry-user"); + + Assert.True(record.ApiCalls >= 1m); + Assert.True(record.BandwidthMb > 0m); + } + [Fact] public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user() { From 8b4b6a7c53021db2dabd18004e88a204d19bce94 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:23:55 +0100 Subject: [PATCH 02/21] 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() { From fae754f1026835da89a5ab7459d8f100501de19b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 10 Oct 2026 20:54:55 +0100 Subject: [PATCH 03/21] Display notifications on PostIt home page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../HomePageNotificationsTests.cs | 199 ++++++++++++++++++ .../PostIt.Tests/YavscApiClientTests.cs | 50 +++++ .../Helpers/ServiceCollectionHelpers.cs | 2 + src/PostIt/PostIt/Services/YavscApiClient.cs | 13 +- .../ViewModels/Layout/HomePageViewModel.cs | 87 +++++++- src/PostIt/PostIt/Views/Layout/HomePage.axaml | 37 +++- .../PostIt/Views/Layout/HomePage.axaml.cs | 9 + src/Yavsc.Api.Client/IYavscApiClient.cs | 4 + .../NotificationsApiClient.cs | 3 + 10 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeca5faa..9b897b724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * [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. +* [PostIt] Affichage des notifications sur la page d'accueil, avec + actualisation, indicateur de chargement et message d'erreur. Les + notifications publiques sont accessibles sans connexion et la liste + est rechargée lors d'un changement de session. ### Changed diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs new file mode 100644 index 000000000..667ea599a --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs @@ -0,0 +1,199 @@ +using System.Net.Http; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class HomePageNotificationsTests +{ + [Fact] + public async Task Refresh_loads_public_notifications_from_the_business_host() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + transport.Response = Task.FromResult(new List { Note(1, "Information") }); + + await vm.RefreshNotificationsAsync(); + + Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath); + Assert.True(transport.WasAnonymous); + Assert.Equal("Information", Assert.Single(vm.Notifications).title); + Assert.False(vm.IsLoadingNotifications); + Assert.False(vm.HasNoNotifications); + Assert.Null(vm.NotificationsError); + } + + [Fact] + public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + vm.Notifications.Add(Note(1, "Old")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.True(vm.HasNoNotifications); + + transport.Response = Task.FromException>(new HttpRequestException("Server unavailable")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.False(vm.HasNoNotifications); + Assert.Contains("Server unavailable", vm.NotificationsError); + Assert.False(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Recovered") }); + await vm.RefreshNotificationsAsync(); + Assert.Null(vm.NotificationsError); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + + [Fact] + public async Task Refresh_ignores_an_older_response_after_a_newer_refresh() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + var oldResponse = new TaskCompletionSource>(); + transport.Response = oldResponse.Task; + var oldLoad = vm.RefreshNotificationsAsync(); + Assert.True(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Current") }); + await vm.RefreshNotificationsAsync(); + oldResponse.SetResult(new List { Note(1, "Stale") }); + await oldLoad; + + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.False(vm.IsLoadingNotifications); + } + + [AvaloniaFact] + public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data() + { + var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json"); + var settings = new Settings(); + var store = new TokenStore(tokenPath); + store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", + DateTimeOffset.UtcNow.AddHours(1), null)); + await using var api = new YavscApiClient(settings, store); + try + { + var session = new SessionStatusViewModel { Api = api }; + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport, session); + session.Refresh(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + var privateResponse = new TaskCompletionSource>(); + transport.Response = privateResponse.Task; + var privateLoad = vm.RefreshNotificationsAsync(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + + vm.Notifications.Add(Note(9, "Private")); + transport.Response = Task.FromResult(new List { Note(2, "Public") }); + session.SetError("Disconnected"); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.True(transport.WasAnonymous); + + privateResponse.SetResult(new List { Note(1, "Late private data") }); + await privateLoad; + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + finally + { + File.Delete(tokenPath); + } + } + + [AvaloniaFact] + public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() + { + var transport = new NotificationTransport + { + Response = Task.FromResult(new List { Note(1, "Notification title") }) + }; + var vm = CreateViewModel(transport); + var page = new HomePage { DataContext = vm }; + var nav = new NavigationPage(); + var window = new Window { Content = nav, Width = 320, Height = 450 }; + try + { + window.Show(); + await nav.PushAsync(page); + window.UpdateLayout(); + + Assert.Single(vm.Notifications); + var texts = page.GetVisualDescendants().OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "Notification title" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + Assert.Contains(texts, t => t.Text == "Notification body" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + var refresh = Assert.Single(page.GetVisualDescendants().OfType