Compare commits
44 changed files with 110 additions and 1011 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,31 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"CorsAllowedOrigins": [
|
||||
"https://preprod-api.$DOMAIN"
|
||||
],
|
||||
"UserFilesRoot": "files"
|
||||
"Blog": "blog"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"EMail": "paul@pschneider.fr"
|
||||
},
|
||||
"Quota": 200000000,
|
||||
"UserFilesRoot": "files",
|
||||
"Blog": "blog",
|
||||
"Avatars": "avatars",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,56 +17,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ 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();
|
||||
|
||||
|
|
@ -94,7 +93,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -39,16 +39,6 @@ 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)
|
||||
{
|
||||
|
|
@ -624,8 +614,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_owner._tokens is not null
|
||||
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
|
||||
if (_owner._tokens is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer", _owner._tokens.AccessToken);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
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
|
||||
|
|
@ -18,26 +13,6 @@ 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
|
||||
|
|
@ -49,16 +24,11 @@ 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,
|
||||
NotificationsApiClient notificationsClient)
|
||||
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
|
||||
{
|
||||
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);
|
||||
|
|
@ -85,59 +55,6 @@ 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()
|
||||
{
|
||||
|
|
@ -311,7 +228,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(), null!)
|
||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
||||
{
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,50 +2,20 @@
|
|||
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>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||
HorizontalAlignment="Stretch">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<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"
|
||||
|
|
@ -91,5 +61,4 @@
|
|||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -9,12 +8,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ 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";
|
||||
|
|
|
|||
|
|
@ -42,10 +42,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -322,8 +322,6 @@ 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 });
|
||||
}
|
||||
|
||||
|
|
@ -384,9 +382,12 @@ 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();
|
||||
|
||||
|
|
@ -413,9 +414,6 @@ 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);
|
||||
|
|
@ -426,12 +424,6 @@ 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
|
||||
|
|
@ -449,8 +441,9 @@ 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 must match the authenticated caller and identifies
|
||||
/// which party of the estimate signed.
|
||||
/// field disambiguates which side of the estimate signed
|
||||
/// because the bearer token belongs to the PostIt OAuth
|
||||
/// client, not the end user.
|
||||
/// </summary>
|
||||
public class SignatureSubmission
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#nullable enable annotations
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
@ -12,7 +10,6 @@ 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;
|
||||
|
|
@ -29,7 +26,6 @@ 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
|
||||
|
|
@ -49,13 +45,11 @@ 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;
|
||||
|
|
@ -239,7 +233,6 @@ 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
|
||||
|
|
@ -306,11 +299,7 @@ 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
|
||||
|
|
@ -334,9 +323,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ 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;
|
||||
|
||||
|
|
@ -21,12 +19,10 @@ namespace Yavsc.Controllers;
|
|||
public class RdvQueryApiController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IYavscMessageSender _messageSender;
|
||||
|
||||
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
||||
public RdvQueryApiController(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_messageSender = messageSender;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
|
@ -97,16 +93,6 @@ 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)
|
||||
{
|
||||
|
|
@ -138,9 +124,6 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
8
src/Yavsc.Blogs/Constants.cs
Normal file
8
src/Yavsc.Blogs/Constants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Yavsc.Blogs;
|
||||
|
||||
public static class BlogConstants
|
||||
{
|
||||
public const string AdminRole = "Admin";
|
||||
public const string ModeratorRole = "Moderator";
|
||||
public const string UserRole = "User";
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
if (string.IsNullOrEmpty(ownerUserName))
|
||||
return NotFound(new { error = "owner not found" });
|
||||
|
||||
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
|
||||
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||
var fi = new FileInfo(physicalPath);
|
||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Yavsc.Models;
|
|||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
namespace Yavsc.Blogs;
|
||||
|
||||
internal class Program
|
||||
|
|
@ -84,9 +85,9 @@ internal class Program
|
|||
})
|
||||
.Configure<SiteSettings>(settings =>
|
||||
{
|
||||
if (settings.UserFilesRoot == null)
|
||||
if (settings.Blog == null)
|
||||
{
|
||||
settings.UserFilesRoot = "blog";
|
||||
settings.Blog = "blog";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -97,11 +98,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")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"https://localhost:5005",
|
||||
"https://api.pschneider.fr"
|
||||
],
|
||||
"UserFilesRoot": "files"
|
||||
"Blog": "blog"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -421,9 +421,9 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
// Renames the blog files
|
||||
var userdirinfo = new DirectoryInfo(
|
||||
Path.Combine(_siteSettings.UserFilesRoot,
|
||||
Path.Combine(_siteSettings.Blog,
|
||||
oldUserName));
|
||||
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
||||
var newdir = Path.Combine(_siteSettings.Blog,
|
||||
model.UserName);
|
||||
if (userdirinfo.Exists)
|
||||
userdirinfo.MoveTo(newdir);
|
||||
|
|
|
|||
|
|
@ -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.UserFilesRoot == null)
|
||||
if (settings.Blog == 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.UserFilesRoot);
|
||||
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
||||
|
||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,28 @@ 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ public class Startup
|
|||
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||
Config.SmtpSetup = smtpSettings.Value;
|
||||
Config.Authority = siteSettings.Value.Authority;
|
||||
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
|
||||
string blogsDir = siteSettings.Value.Blog ?? 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);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
|
|||
{
|
||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||
return View(new UserDirectoryInfo(
|
||||
siteSettings.UserFilesRoot,
|
||||
siteSettings.Blog,
|
||||
uid, dirname));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
"Avatars": "avatars",
|
||||
"Quota": 200000000,
|
||||
"Bills": "bills",
|
||||
"UserFilesRoot": "files",
|
||||
"Blog": "blog",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
|
|||
this ClaimsPrincipal user,
|
||||
string subpath, SiteSettings siteSettings)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, user.UserName);
|
||||
var root = Path.Combine(siteSettings.Blog, 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.UserFilesRoot, userId, subdir);
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
|
||||
return di;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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}.";
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public class BlogSpotService
|
|||
_context = context;
|
||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
if (siteSettings.Value.UserFilesRoot == null)
|
||||
if (siteSettings.Value.Blog == 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.UserFilesRoot,
|
||||
siteSettings.Blog,
|
||||
user.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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
|
||||
{
|
||||
|
|
@ -31,7 +30,7 @@ namespace Yavsc.Services
|
|||
IHubContext<ChatHub> hubContext
|
||||
)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
||||
_logger = loggerFactory.CreateLogger<MailSender>();
|
||||
_emailSender = emailSender;
|
||||
siteSettings = sitesOptions?.Value;
|
||||
this.hubContext = hubContext;
|
||||
|
|
@ -71,51 +70,34 @@ 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
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);
|
||||
result.error = "user has not confirmed his email address.";
|
||||
continue;
|
||||
}
|
||||
else
|
||||
if (user.Email == null)
|
||||
{
|
||||
response.failure++;
|
||||
result.error = "user has no legacy email address.";
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = ev.CreateBody();
|
||||
|
||||
|
||||
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
|
||||
|
||||
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||
subject,
|
||||
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
||||
body + Environment.NewLine);
|
||||
response.success++;
|
||||
}
|
||||
|
||||
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
||||
if (cxIds == null)
|
||||
|
|
@ -143,6 +125,7 @@ namespace Yavsc.Services
|
|||
|
||||
response.success++;
|
||||
}
|
||||
results.Add(result);
|
||||
}
|
||||
response.results = results.ToArray();
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ namespace Yavsc
|
|||
public string DataDir { get; set; }
|
||||
public string Avatars { get; set; }
|
||||
public long Quota { get; set; }
|
||||
public string UserFilesRoot { get; set; }
|
||||
public string Blog { get; set; }
|
||||
public string Bills { get; set; }
|
||||
public string GitRepository { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,6 @@ 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;
|
||||
|
||||
|
|
@ -67,13 +64,8 @@ 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.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.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
|
|
@ -212,9 +204,23 @@ 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>();
|
||||
|
||||
|
|
@ -328,8 +334,6 @@ 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>());
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ using Yavsc.Models;
|
|||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
|
|
@ -139,8 +138,6 @@ 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]
|
||||
|
|
@ -168,8 +165,6 @@ 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]
|
||||
|
|
@ -197,48 +192,6 @@ 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]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ 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;
|
||||
|
||||
|
|
@ -37,72 +34,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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;
|
||||
|
|
@ -99,66 +98,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
|||
// on real files.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.UserFilesRoot = BlogFilesRoot;
|
||||
s.Blog = BlogFilesRoot;
|
||||
});
|
||||
|
||||
// Real BlogSpotService — same instance the production host
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
|||
// BlogsWebServerFixture.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
});
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
"FavIcon": "/favicon.ico",
|
||||
"Icon": "/images/yavsc.png",
|
||||
"GitRepository": "testingrepo",
|
||||
"UserFilesRoot": "files",
|
||||
"Blog": "blog",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
"TempDir": "temp",
|
||||
"Avatars": "avatars",
|
||||
"Owner": {
|
||||
"Name": "Site Owner Name",
|
||||
|
|
|
|||
|
|
@ -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.UserFilesRoot = "server-tests-files-root");
|
||||
services.Configure<SiteSettings>(s => s.Blog = "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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue