Compare commits

..
Author SHA1 Message Date
446b595077
renaming of the 3Blog3 setting => UserFilesRoot
All checks were successful
Dotnet build and test / build (pull_request) Successful in 33m37s
2026-10-11 00:16:44 +01:00
f9d95fd98f
Notify performers of API bookings and client-signed estimates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 00:14:33 +01:00
fae754f102
Display notifications on PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:54:55 +01:00
8b4b6a7c53
Add notifications API for PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:23:55 +01:00
7ee0c30500
Fix authenticated resource usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:03:19 +01:00
44 changed files with 1011 additions and 110 deletions

View file

@ -1,6 +1,31 @@
# Changelog
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [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
### 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

View file

@ -8,7 +8,7 @@
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -30,7 +30,7 @@
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"UserFilesRoot": "files",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",

View file

@ -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<Notification> { 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<List<Notification>>(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<Notification> { 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<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { 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<List<Notification>>();
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<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { 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<Notification> { 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<TextBlock>().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<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -17,6 +17,56 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);

View file

@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);

View file

@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_owner._tokens is not null)
if (_owner._tokens is not null
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken);

View file

@ -1,11 +1,16 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
public ObservableCollection<Notification> Notifications { get; } = new();
private readonly NotificationsApiClient? _notificationsClient;
private int _notificationsLoadVersion;
private bool _isLoadingNotifications;
private string? _notificationsError;
private bool _notificationsLoaded;
public bool IsLoadingNotifications
{
get => _isLoadingNotifications;
private set => SetProperty(ref _isLoadingNotifications, value);
}
public string? NotificationsError
{
get => _notificationsError;
private set => SetProperty(ref _notificationsError, value);
}
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus,
NotificationsApiClient notificationsClient)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
_notificationsClient = notificationsClient;
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public async Task RefreshNotificationsAsync()
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
var version = ++_notificationsLoadVersion;
Notifications.Clear();
NotificationsError = null;
_notificationsLoaded = false;
OnPropertyChanged(nameof(HasNoNotifications));
IsLoadingNotifications = true;
try
{
var notifications = SessionStatus.IsLoggedIn
? await _notificationsClient.GetCurrentAsync()
: await _notificationsClient.GetPublicAsync();
if (version != _notificationsLoadVersion)
return;
if (notifications is null)
throw new InvalidOperationException("The notifications response is empty.");
foreach (var notification in notifications)
Notifications.Add(notification);
_notificationsLoaded = true;
}
catch (Exception ex)
{
if (version == _notificationsLoadVersion)
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
}
finally
{
if (version == _notificationsLoadVersion)
{
IsLoadingNotifications = false;
OnPropertyChanged(nameof(HasNoNotifications));
}
}
}
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
}
private async Task OpenActivitiesAsync()
{
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
{
}

View file

@ -2,20 +2,50 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:lang="using:Yavsc.Lang"
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel"
Header="Home">
<Design.DataContext>
<vm:HomePageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
HorizontalAlignment="Stretch">
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Actualiser"
Command="{Binding RefreshNotificationsCommand}"
IsEnabled="{Binding !IsLoadingNotifications}"/>
</Grid>
<ProgressBar IsIndeterminate="True" Height="4"
IsVisible="{Binding IsLoadingNotifications}"/>
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
<TextBlock Text="Aucune notification pour le moment."
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="messaging:Notification">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Les Salons de discussion"
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
@ -61,4 +91,5 @@
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,4 +1,5 @@
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
&& vm.RefreshNotificationsCommand.CanExecute(null))
_ = vm.RefreshNotificationsAsync();
}
}

View file

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

View file

@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
/// </summary>
HttpClient Http { get; }
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>

View file

@ -0,0 +1,61 @@
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;
/// <summary>
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
/// </summary>
public sealed class NotificationsApiClient
{
private const string PathPrefix = Constants.NotificationsPath;
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public NotificationsApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
public Task<List<Notification>> GetCurrentAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"),
ct: ct);
}
public Task<List<Notification>> GetAllAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute(PathPrefix),
ct: ct);
}
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), 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);
}
}

View file

@ -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<ActionResult<List<Notification>>> GetCurrentUserNotifications()
{
var currentUserId = User.GetUserId();
var isAdmin = User.IsInRole(Constants.AdminGroupName);
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
? new HashSet<long>()
: 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);
}
}

View file

@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
estimate.ClientValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
}
@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers
.FirstOrDefaultAsync(e => e.Id == id, token);
if (estimate is null) return NotFound(new { Error = "estimate not found" });
// The signer is identified by userId in the body, not
// by the bearer token, because the OAuth scope we
// carry is for the API client (PostIt), not the end
// user. We trust the body's userId to match either
// Owner or Client, and reject everything else.
var userId = body.SignerUserId;
if (userId != User.GetUserId())
return Forbid();
if (userId != estimate.OwnerId && userId != estimate.ClientId)
return Forbid();
@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers
var signature = await EstimateSignaturePersister.StageAsync(
dbContext, id, type, userId, payload, token);
if (type == SignatureType.Client)
estimate.ClientValidationDate = DateTime.UtcNow;
try
{
await dbContext.SaveChangesAsync(token);
@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (type == SignatureType.Client)
{
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
}
var location = Url.Action(nameof(Sign), new { id })
?? $"/api/bill/estimate/{id}/sign";
return Created(location, new
@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// field disambiguates which side of the estimate signed
/// because the bearer token belongs to the PostIt OAuth
/// client, not the end user.
/// field must match the authenticated caller and identifies
/// which party of the estimate signed.
/// </summary>
public class SignatureSubmission
{

View file

@ -1,3 +1,5 @@
#nullable enable annotations
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -10,6 +12,7 @@ using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Messaging;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
@ -26,6 +29,7 @@ namespace Yavsc.ApiControllers
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
private readonly IRazorViewEngine viewEngine;
private readonly IYavscMessageSender messageSender;
// The view-rendering dependencies (siteSettings, viewEngine) are
// optional: only EstimateTex/EstimatePdf use them, and those run in
@ -45,11 +49,13 @@ namespace Yavsc.ApiControllers
public FrontOfficeApiController(
ApplicationDbContext context,
ILoggerFactory loggerFactory,
IYavscMessageSender messageSender,
IOptions<SiteSettings> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{
dbContext = context;
this.messageSender = messageSender;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
@ -233,6 +239,7 @@ namespace Yavsc.ApiControllers
if (query is null) return BadRequest(new { Error = "query not found" });
Signature? signature = null;
ClientEstimateSignedEvent? signedEvent = null;
// actingRole is the role the caller is signing / accepting
// as. It defaults to the role inferred from the caller's
// identity, but when a signature is submitted the caller
@ -299,7 +306,11 @@ namespace Yavsc.ApiControllers
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
{
estimate.ClientValidationDate = DateTime.UtcNow;
var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token);
signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName);
}
}
// The party-specific status records who accepted: the
@ -323,6 +334,9 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (signedEvent is not null)
await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok(new
{
queryId,

View file

@ -10,6 +10,8 @@ using Yavsc.Models.Billing;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Helpers;
using Yavsc.Services;
namespace Yavsc.Controllers;
@ -19,10 +21,12 @@ namespace Yavsc.Controllers;
public class RdvQueryApiController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IYavscMessageSender _messageSender;
public RdvQueryApiController(ApplicationDbContext context)
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
{
_context = context;
_messageSender = messageSender;
}
[HttpGet]
@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller
return BadRequest(new { Error = "location is required" });
}
var performer = await _context.Performers
.AsNoTracking()
.SingleOrDefaultAsync(p => p.PerformerId == query.PerformerId, cancellationToken);
if (performer is null)
return BadRequest(new { Error = "performer not found" });
var client = await _context.Users.SingleOrDefaultAsync(u => u.Id == uid, cancellationToken);
if (client is null)
return Unauthorized();
query.Client = client;
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
if (resolvedLocation is null)
{
@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller
throw;
}
if (performer.AcceptNotifications && performer.AcceptPublicContact)
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
}

View file

@ -134,11 +134,11 @@ internal class Program
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default");
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute();

View file

@ -1,8 +0,0 @@
namespace Yavsc.Blogs;
public static class BlogConstants
{
public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator";
public const string UserRole = "User";
}

View file

@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });

View file

@ -9,7 +9,6 @@ using Yavsc.Models;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Yavsc.Blogs;
internal class Program
@ -85,9 +84,9 @@ internal class Program
})
.Configure<SiteSettings>(settings =>
{
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
settings.Blog = "blog";
settings.UserFilesRoot = "blog";
}
});
@ -98,11 +97,11 @@ internal class Program
{
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default")
;
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")

View file

@ -9,7 +9,7 @@
"https://localhost:5005",
"https://api.pschneider.fr"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -421,9 +421,9 @@ namespace Yavsc.Controllers
{
// Renames the blog files
var userdirinfo = new DirectoryInfo(
Path.Combine(_siteSettings.Blog,
Path.Combine(_siteSettings.UserFilesRoot,
oldUserName));
var newdir = Path.Combine(_siteSettings.Blog,
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
model.UserName);
if (userdirinfo.Exists)
userdirinfo.MoveTo(newdir);

View file

@ -1267,7 +1267,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
{
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
}
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
}
@ -1284,7 +1284,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
}
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();

View file

@ -11,28 +11,6 @@ namespace Yavsc.Helpers
public static class EventHelpers
{
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
{
var yaev = new RdvQueryEvent(subtopic)
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
ActivityCode = query.ActivityCode,
BillingCode = BillingCodes.Rdv
};
return yaev;
}
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
IStringLocalizer<HairCutQuery> SR)
{

View file

@ -23,10 +23,10 @@ public class Startup
ResourcesHelpers.GlobalLocalizer = localizer;
Config.SmtpSetup = smtpSettings.Value;
Config.Authority = siteSettings.Value.Authority;
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
Config.PayPalSettings = payPalSettings.Value;
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

View file

@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
{
string uid = ViewContext.HttpContext.User.GetUserId();
return View(new UserDirectoryInfo(
siteSettings.Blog,
siteSettings.UserFilesRoot,
uid, dirname));
}
}

View file

@ -31,7 +31,7 @@
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"UserFilesRoot": "files",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"

View file

@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
@ -80,7 +80,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(dirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
@ -110,7 +110,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(fromDirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
@ -136,7 +136,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
string dest;
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
var fo = new FileInfo(Path.Combine(root, fileNameDest));
@ -318,7 +318,7 @@ namespace Yavsc.Server.Helpers
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
{
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
return di;
}

View file

@ -0,0 +1,29 @@
using Yavsc.Abstract.Identity;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Messaging;
using Yavsc.Models.Workflow;
namespace Yavsc.Helpers;
public static class RdvQueryEventHelpers
{
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
=> new(subtopic)
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo
{
UserName = query.Client.UserName,
UserId = query.ClientId,
Avatar = query.Client.Avatar
},
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
ActivityCode = query.ActivityCode,
BillingCode = BillingCodes.Rdv
};
}

View file

@ -0,0 +1,23 @@
using Yavsc.Interfaces.Workflow;
using Yavsc.Models.Billing;
namespace Yavsc.Models.Messaging;
public sealed class ClientEstimateSignedEvent : IEvent
{
public ClientEstimateSignedEvent(Estimate estimate, string sender)
{
EstimateId = estimate.Id;
EstimateTitle = estimate.Title;
PerformerId = estimate.OwnerId;
Sender = sender;
}
public long EstimateId { get; }
public string EstimateTitle { get; }
public string PerformerId { get; }
public string Topic => "ClientEstimateSigned";
public string Sender { get; set; }
public string Title => "Devis signé par le client";
public string CreateBody() => $"{Sender} a signé le devis n°{EstimateId} : {EstimateTitle}.";
}

View file

@ -36,7 +36,7 @@ public class BlogSpotService
_context = context;
this.fileSystemAuthManager = fileSystemAuthManager;
this.siteSettings = siteSettings.Value;
if (siteSettings.Value.Blog == null)
if (siteSettings.Value.UserFilesRoot == null)
{
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
}
@ -56,7 +56,7 @@ public class BlogSpotService
{
string blogFilesSubdir = $"blogs/{postId}";
string destDir = Path.Combine(
siteSettings.Blog,
siteSettings.UserFilesRoot,
user.UserName,
blogFilesSubdir);
var di = new DirectoryInfo(destDir);

View file

@ -9,6 +9,7 @@ using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
using Yavsc.Models.Messaging;
using Yavsc.Server.Hubs;
using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Services
{
@ -30,7 +31,7 @@ namespace Yavsc.Services
IHubContext<ChatHub> hubContext
)
{
_logger = loggerFactory.CreateLogger<MailSender>();
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
_emailSender = emailSender;
siteSettings = sitesOptions?.Value;
this.hubContext = hubContext;
@ -70,34 +71,51 @@ namespace Yavsc.Services
};
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
results.Add(result);
if (user == null)
{
response.failure++;
result.error = "no such user.";
continue;
}
if (!user.EmailConfirmed)
{
response.failure++;
result.error = "user has not confirmed his email address.";
continue;
}
if (user.Email == null)
{
response.failure++;
result.error = "user has no legacy email address.";
continue;
}
var body = ev.CreateBody();
var subject = ev is ClientEstimateSignedEvent signed
? signed.Title
: $"{ev.Sender} (un client) vous demande un rendez-vous";
if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent)
{
_dbContext.Notification.Add(new Notification
{
title = subject.Length > 1024 ? subject[..1024] : subject,
body = body.Length > 512 ? body[..512] : body,
Target = $"user/{userId}",
tag = ev switch
{
ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed",
RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}",
_ => throw new InvalidOperationException("Unsupported persistent notification event.")
},
click_action = "Fermer"
});
await _dbContext.SaveChangesAsync(ev.Sender);
}
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
{
response.failure++;
result.error = !user.EmailConfirmed
? "user has not confirmed his email address."
: "user has no legacy email address.";
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
}
else
{
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
$"{ev.Sender} (un client) vous demande un rendez-vous",
subject,
body + Environment.NewLine);
response.success++;
}
var cxIds = _cxManager.GetConnexionIds(user.UserName);
if (cxIds == null)
@ -125,7 +143,6 @@ namespace Yavsc.Services
response.success++;
}
results.Add(result);
}
response.results = results.ToArray();
return response;

View file

@ -49,7 +49,7 @@ namespace Yavsc
public string DataDir { get; set; }
public string Avatars { get; set; }
public long Quota { get; set; }
public string Blog { get; set; }
public string UserFilesRoot { get; set; }
public string Bills { get; set; }
public string GitRepository { get; set; }

View file

@ -0,0 +1,78 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.ApiControllers;
using Yavsc.Models;
using Yavsc.Models.Billing;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
[Theory]
[InlineData("bob", "bob", true)]
[InlineData("alice", "alice", false)]
[InlineData("alice", "bob", false)]
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
string caller, string signer, bool notifies)
{
fixture.ResetAndSeedActivityGraph();
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
Title = "Devis notification", Description = "Devis de test",
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
};
db.Estimates.Add(estimate);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
}
};
controller.Url = new SignatureUrlHelper();
var result = await controller.Sign(estimate.Id, new SignatureSubmission
{
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
}, TestContext.Current.CancellationToken);
if (caller != signer)
{
Assert.IsType<ForbidResult>(result);
Assert.Empty(db.Signatures);
}
else
Assert.IsType<CreatedResult>(result);
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
if (notifies)
{
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
Assert.NotEqual(default, estimate.ClientValidationDate);
}
else
{
Assert.Empty(db.Notification);
Assert.Empty(delivery.Emails);
}
}
private sealed class SignatureUrlHelper : IUrlHelper
{
public ActionContext ActionContext => throw new NotSupportedException();
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
public string? Content(string? contentPath) => throw new NotSupportedException();
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
}
}

View file

@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.Tests.Shared;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
namespace Yavsc.Api.Test.Fixtures;
@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddLocalization();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
builder.Services.AddSingleton<RecordingMessageDelivery>();
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdministratorOnly", policy =>
@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
return builder.ToString();
}
private sealed class NoopMessageSender : IYavscMessageSender
{
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
=> Task.FromResult(new MessageWithPayloadResponse());
}
public void ResetAndSeedActivityGraph()
{
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
// CommandLine reference NominativeServiceCommand (CommandId) and
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
db.DismissClicked.RemoveRange(db.DismissClicked);
db.Notification.RemoveRange(db.Notification);
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
namespace Yavsc.Api.Test.Fixtures;
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
{
public record Email(string Recipient, string Subject, string Body);
public record Push(string ConnectionId, string Method, object?[] Arguments);
public ConcurrentQueue<Email> Emails { get; } = new();
public ConcurrentQueue<Push> Pushes { get; } = new();
IHubClients IHubContext<ChatHub>.Clients => this;
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IClientProxy Group(string groupName) => throw new NotSupportedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
public IClientProxy User(string userId) => throw new NotSupportedException();
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
Emails.Enqueue(new Email(email, subject, htmlMessage));
return Task.FromResult(Guid.NewGuid().ToString());
}
public void Clear()
{
Emails.Clear();
Pushes.Clear();
}
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
return Task.CompletedTask;
}
}
}

View file

@ -10,6 +10,7 @@ using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
// Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Pro, signature.Type);
Assert.Equal("alice", signature.SignerId);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("bob", signature.SignerId);
var notification = Assert.Single(assertDb.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag);
Assert.Contains("bob", notification.body);
Assert.Contains("Devis prestation", notification.body);
var email = Assert.Single(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
Assert.Equal("alice@example.test", email.Recipient);
Assert.Equal("Devis signé par le client", email.Subject);
}
[Fact]
public async Task Client_signature_pushes_to_the_performer_even_without_confirmed_email()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Users.Single(u => u.Id == "alice").EmailConfirmed = false;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("signed-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Empty(delivery.Emails);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("signed-alice", push.ConnectionId);
Assert.Equal("ClientEstimateSigned", push.Arguments[0]);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag);
}
finally
{
manager.OnDisconnected("signed-alice");
}
}
[Fact]

View file

@ -5,6 +5,9 @@ using Yavsc;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
return http;
}
[Fact]
public async Task PostQuery_notifies_performer_by_database_email_and_signalr()
{
_fixture.ResetAndSeedRdvQueryGraph();
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("rdv-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", ClientId = "spoofed",
Consent = true, EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Notification test", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande notifiée", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var query = await response.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notification = Assert.Single(db.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"rdv/{query!.Id}", notification.tag);
Assert.Contains("bob", notification.title);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("rdv-alice", push.ConnectionId);
Assert.Equal("push", push.Method);
Assert.Equal("/topic/RdvQuery", push.Arguments[0]);
}
finally
{
manager.OnDisconnected("rdv-alice");
}
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task PostQuery_respects_performer_contact_preferences(bool acceptsNotifications, bool acceptsPublicContact)
{
_fixture.ResetAndSeedRdvQueryGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var performer = db.Performers.Single(p => p.PerformerId == "alice");
performer.AcceptNotifications = acceptsNotifications;
performer.AcceptPublicContact = acceptsPublicContact;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Contact refused", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
public async Task Billing_rdv_route_supports_crud()
{

View file

@ -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;
@ -98,6 +99,66 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
Assert.True(record.BandwidthMb >= 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<Yavsc.Models.ApplicationDbContext>();
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 GetCurrentUserNotifications_returns_public_and_targeted_notifications()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
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<List<Notification>>(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()
{

View file

@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// on real files.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = BlogFilesRoot;
s.UserFilesRoot = BlogFilesRoot;
});
// Real BlogSpotService — same instance the production host

View file

@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
// BlogsWebServerFixture.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
});
builder.Services.AddScoped<BlogSpotService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();

View file

@ -9,9 +9,9 @@
"FavIcon": "/favicon.ico",
"Icon": "/images/yavsc.png",
"GitRepository": "testingrepo",
"Blog": "blog",
"UserFilesRoot": "files",
"Bills": "bills",
"TempDir": "temp",
"TempDir": "temp",
"Avatars": "avatars",
"Owner": {
"Name": "Site Owner Name",

View file

@ -115,7 +115,7 @@ public sealed class ServerServicesFixture : IDisposable
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
services.Configure<SiteSettings>(s => s.UserFilesRoot = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.
@ -449,4 +449,4 @@ public sealed class ServerServicesFixture : IDisposable
public void SetAccess(long circleId, string normalizedFullPath,
FileAccessRight access) { }
}
}
}