Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
446b595077 |
|||
|
f9d95fd98f |
|||
|
fae754f102 |
|||
|
8b4b6a7c53 |
|||
|
7ee0c30500 |
44 changed files with 1011 additions and 110 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,6 +1,31 @@
|
||||||
# Changelog
|
# 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
|
## [1.0.8-rc24] - unstable
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"CorsAllowedOrigins": [
|
"CorsAllowedOrigins": [
|
||||||
"https://preprod-api.$DOMAIN"
|
"https://preprod-api.$DOMAIN"
|
||||||
],
|
],
|
||||||
"Blog": "blog"
|
"UserFilesRoot": "files"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"EMail": "paul@pschneider.fr"
|
"EMail": "paul@pschneider.fr"
|
||||||
},
|
},
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
|
|
|
||||||
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,56 @@ namespace PostIt.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class YavscApiClientTests
|
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()
|
private static int GetFreePort()
|
||||||
{
|
{
|
||||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
|
||||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||||
|
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton(frontClient);
|
services.AddSingleton(frontClient);
|
||||||
services.AddSingleton(resourceUsageClient);
|
services.AddSingleton(resourceUsageClient);
|
||||||
|
services.AddSingleton(notificationsClient);
|
||||||
services.AddSingleton(userFilesClient);
|
services.AddSingleton(userFilesClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
|
|
||||||
private RefreshTokenRecord? _tokens;
|
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)
|
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
||||||
{
|
{
|
||||||
|
|
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
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(
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
"Bearer", _owner._tokens.AccessToken);
|
"Bearer", _owner._tokens.AccessToken);
|
||||||
return base.SendAsync(request, cancellationToken);
|
return base.SendAsync(request, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels.Chat;
|
using PostIt.ViewModels.Chat;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public class HomePageViewModel : ViewModelBase
|
public class HomePageViewModel : ViewModelBase
|
||||||
|
|
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public YavscApiClient Api { get; }
|
public YavscApiClient Api { get; }
|
||||||
public Settings Settings { get; }
|
public Settings Settings { get; }
|
||||||
public SessionStatusViewModel SessionStatus { 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!";
|
private string _welcomeText = "Welcome to PostIt!";
|
||||||
public string WelcomeText
|
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 CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => false; 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;
|
Api = api;
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
SessionStatus = sessionStatus;
|
SessionStatus = sessionStatus;
|
||||||
|
_notificationsClient = notificationsClient;
|
||||||
|
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||||
|
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||||
|
() => _notificationsClient is not null);
|
||||||
|
|
||||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||||
|
|
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenMyFiles { 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()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||||
/// designer-only duplicate instance is therefore harmless.
|
/// designer-only duplicate instance is therefore harmless.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,50 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
xmlns:lang="using:Yavsc.Lang"
|
xmlns:lang="using:Yavsc.Lang"
|
||||||
|
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
||||||
x:Class="PostIt.Views.HomePage"
|
x:Class="PostIt.Views.HomePage"
|
||||||
x:DataType="vm:HomePageViewModel"
|
x:DataType="vm:HomePageViewModel"
|
||||||
Header="Home">
|
Header="Home">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:HomePageViewModel />
|
<vm:HomePageViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
VerticalAlignment="Center"
|
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||||
Spacing="12">
|
HorizontalAlignment="Stretch">
|
||||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center"/>
|
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"
|
<Button Content="Les Salons de discussion"
|
||||||
Command="{Binding OpenChat}"
|
Command="{Binding OpenChat}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|
@ -61,4 +91,5 @@
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(e);
|
||||||
|
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
|
||||||
|
&& vm.RefreshNotificationsCommand.CanExecute(null))
|
||||||
|
_ = vm.RefreshNotificationsAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ namespace Yavsc
|
||||||
public const string ResourceUsagePath = "resource-usage";
|
public const string ResourceUsagePath = "resource-usage";
|
||||||
public const string ResourceUsageCurrentUserRoute = "me";
|
public const string ResourceUsageCurrentUserRoute = "me";
|
||||||
public const string ResourceUsageAdminRoute = "admin";
|
public const string ResourceUsageAdminRoute = "admin";
|
||||||
|
public const string NotificationsPath = "notifications";
|
||||||
|
public const string NotificationsCurrentUserRoute = "me";
|
||||||
public const string ResourceUsageDefaultCurrency = "EUR";
|
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||||
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||||
public const string ResourceUsageAdminDisplayName = "administrator";
|
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
HttpClient Http { get; }
|
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>
|
/// <summary>Call a JSON endpoint with a typed return value.</summary>
|
||||||
/// <param name="method">HTTP verb.</param>
|
/// <param name="method">HTTP verb.</param>
|
||||||
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
||||||
|
|
|
||||||
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal file
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers
|
||||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
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 });
|
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers
|
||||||
.FirstOrDefaultAsync(e => e.Id == id, token);
|
.FirstOrDefaultAsync(e => e.Id == id, token);
|
||||||
if (estimate is null) return NotFound(new { Error = "estimate not found" });
|
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;
|
var userId = body.SignerUserId;
|
||||||
|
if (userId != User.GetUserId())
|
||||||
|
return Forbid();
|
||||||
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
|
|
@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers
|
||||||
var signature = await EstimateSignaturePersister.StageAsync(
|
var signature = await EstimateSignaturePersister.StageAsync(
|
||||||
dbContext, id, type, userId, payload, token);
|
dbContext, id, type, userId, payload, token);
|
||||||
|
|
||||||
|
if (type == SignatureType.Client)
|
||||||
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await dbContext.SaveChangesAsync(token);
|
await dbContext.SaveChangesAsync(token);
|
||||||
|
|
@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
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 })
|
var location = Url.Action(nameof(Sign), new { id })
|
||||||
?? $"/api/bill/estimate/{id}/sign";
|
?? $"/api/bill/estimate/{id}/sign";
|
||||||
return Created(location, new
|
return Created(location, new
|
||||||
|
|
@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
||||||
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
||||||
/// field disambiguates which side of the estimate signed
|
/// field must match the authenticated caller and identifies
|
||||||
/// because the bearer token belongs to the PostIt OAuth
|
/// which party of the estimate signed.
|
||||||
/// client, not the end user.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SignatureSubmission
|
public class SignatureSubmission
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#nullable enable annotations
|
||||||
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
@ -10,6 +12,7 @@ using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Models.Messaging;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
|
|
@ -26,6 +29,7 @@ namespace Yavsc.ApiControllers
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
private readonly SiteSettings siteSettings;
|
private readonly SiteSettings siteSettings;
|
||||||
private readonly IRazorViewEngine viewEngine;
|
private readonly IRazorViewEngine viewEngine;
|
||||||
|
private readonly IYavscMessageSender messageSender;
|
||||||
|
|
||||||
// The view-rendering dependencies (siteSettings, viewEngine) are
|
// The view-rendering dependencies (siteSettings, viewEngine) are
|
||||||
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
||||||
|
|
@ -45,11 +49,13 @@ namespace Yavsc.ApiControllers
|
||||||
public FrontOfficeApiController(
|
public FrontOfficeApiController(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
|
IYavscMessageSender messageSender,
|
||||||
IOptions<SiteSettings> siteSettings = null,
|
IOptions<SiteSettings> siteSettings = null,
|
||||||
IRazorViewEngine viewEngine = null,
|
IRazorViewEngine viewEngine = null,
|
||||||
IBillingService billing = null)
|
IBillingService billing = null)
|
||||||
{
|
{
|
||||||
dbContext = context;
|
dbContext = context;
|
||||||
|
this.messageSender = messageSender;
|
||||||
this.billing = billing ?? new BillingService(context);
|
this.billing = billing ?? new BillingService(context);
|
||||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||||
this.siteSettings = siteSettings?.Value;
|
this.siteSettings = siteSettings?.Value;
|
||||||
|
|
@ -233,6 +239,7 @@ namespace Yavsc.ApiControllers
|
||||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
Signature? signature = null;
|
Signature? signature = null;
|
||||||
|
ClientEstimateSignedEvent? signedEvent = null;
|
||||||
// actingRole is the role the caller is signing / accepting
|
// actingRole is the role the caller is signing / accepting
|
||||||
// as. It defaults to the role inferred from the caller's
|
// as. It defaults to the role inferred from the caller's
|
||||||
// identity, but when a signature is submitted the caller
|
// identity, but when a signature is submitted the caller
|
||||||
|
|
@ -299,7 +306,11 @@ namespace Yavsc.ApiControllers
|
||||||
if (actingRole == ActorRole.Provider)
|
if (actingRole == ActorRole.Provider)
|
||||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||||
else
|
else
|
||||||
|
{
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
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
|
// 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 });
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
queryId,
|
queryId,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Relationship;
|
using Yavsc.Models.Relationship;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
using Yavsc.Helpers;
|
||||||
|
using Yavsc.Services;
|
||||||
|
|
||||||
namespace Yavsc.Controllers;
|
namespace Yavsc.Controllers;
|
||||||
|
|
||||||
|
|
@ -19,10 +21,12 @@ namespace Yavsc.Controllers;
|
||||||
public class RdvQueryApiController : Controller
|
public class RdvQueryApiController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IYavscMessageSender _messageSender;
|
||||||
|
|
||||||
public RdvQueryApiController(ApplicationDbContext context)
|
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_messageSender = messageSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller
|
||||||
return BadRequest(new { Error = "location is required" });
|
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);
|
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
|
||||||
if (resolvedLocation is null)
|
if (resolvedLocation is null)
|
||||||
{
|
{
|
||||||
|
|
@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (performer.AcceptNotifications && performer.AcceptPublicContact)
|
||||||
|
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
|
||||||
|
|
||||||
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,11 @@ internal class Program
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
|
|
||||||
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
.UseAuthorization()
|
.UseAuthorization()
|
||||||
|
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||||
.UseCors("default");
|
.UseCors("default");
|
||||||
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
||||||
app.MapDefaultControllerRoute();
|
app.MapDefaultControllerRoute();
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
if (string.IsNullOrEmpty(ownerUserName))
|
if (string.IsNullOrEmpty(ownerUserName))
|
||||||
return NotFound(new { error = "owner not found" });
|
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);
|
var fi = new FileInfo(physicalPath);
|
||||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ using Yavsc.Models;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
namespace Yavsc.Blogs;
|
namespace Yavsc.Blogs;
|
||||||
|
|
||||||
internal class Program
|
internal class Program
|
||||||
|
|
@ -85,9 +84,9 @@ internal class Program
|
||||||
})
|
})
|
||||||
.Configure<SiteSettings>(settings =>
|
.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())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
.UseAuthorization()
|
.UseAuthorization()
|
||||||
|
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
||||||
.UseCors("default")
|
.UseCors("default")
|
||||||
;
|
;
|
||||||
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"https://localhost:5005",
|
"https://localhost:5005",
|
||||||
"https://api.pschneider.fr"
|
"https://api.pschneider.fr"
|
||||||
],
|
],
|
||||||
"Blog": "blog"
|
"UserFilesRoot": "files"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
"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
|
// Renames the blog files
|
||||||
var userdirinfo = new DirectoryInfo(
|
var userdirinfo = new DirectoryInfo(
|
||||||
Path.Combine(_siteSettings.Blog,
|
Path.Combine(_siteSettings.UserFilesRoot,
|
||||||
oldUserName));
|
oldUserName));
|
||||||
var newdir = Path.Combine(_siteSettings.Blog,
|
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
||||||
model.UserName);
|
model.UserName);
|
||||||
if (userdirinfo.Exists)
|
if (userdirinfo.Exists)
|
||||||
userdirinfo.MoveTo(newdir);
|
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.");
|
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.");
|
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.");
|
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();
|
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,6 @@ namespace Yavsc.Helpers
|
||||||
|
|
||||||
public static class EventHelpers
|
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,
|
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
|
||||||
IStringLocalizer<HairCutQuery> SR)
|
IStringLocalizer<HairCutQuery> SR)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ public class Startup
|
||||||
ResourcesHelpers.GlobalLocalizer = localizer;
|
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||||
Config.SmtpSetup = smtpSettings.Value;
|
Config.SmtpSetup = smtpSettings.Value;
|
||||||
Config.Authority = siteSettings.Value.Authority;
|
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 billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
|
||||||
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
|
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
|
||||||
|
|
||||||
Config.PayPalSettings = payPalSettings.Value;
|
Config.PayPalSettings = payPalSettings.Value;
|
||||||
|
|
||||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
|
||||||
{
|
{
|
||||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||||
return View(new UserDirectoryInfo(
|
return View(new UserDirectoryInfo(
|
||||||
siteSettings.Blog,
|
siteSettings.UserFilesRoot,
|
||||||
uid, dirname));
|
uid, dirname));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
"DataDir": "data",
|
"DataDir": "data",
|
||||||
"GitRepository": "git"
|
"GitRepository": "git"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
|
||||||
this ClaimsPrincipal user,
|
this ClaimsPrincipal user,
|
||||||
string subpath, SiteSettings siteSettings)
|
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 (!string.IsNullOrWhiteSpace(subpath))
|
||||||
{
|
{
|
||||||
if (!subpath.IsValidYavscPath())
|
if (!subpath.IsValidYavscPath())
|
||||||
|
|
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
|
||||||
|
|
||||||
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
|
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));
|
var fi = new FileInfo(Path.Combine(root, fileName));
|
||||||
if (!fi.Exists) return;
|
if (!fi.Exists) return;
|
||||||
// Capture the length BEFORE deleting: FileInfo.Length throws
|
// 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)
|
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))
|
if (string.IsNullOrEmpty(dirName))
|
||||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
|
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)
|
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))
|
if (string.IsNullOrEmpty(fromDirName))
|
||||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
|
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)
|
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));
|
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||||
string dest;
|
string dest;
|
||||||
|
|
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
|
||||||
}
|
}
|
||||||
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
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));
|
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||||
var fo = new FileInfo(Path.Combine(root, fileNameDest));
|
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)
|
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;
|
return di;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal file
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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}.";
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ public class BlogSpotService
|
||||||
_context = context;
|
_context = context;
|
||||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||||
this.siteSettings = siteSettings.Value;
|
this.siteSettings = siteSettings.Value;
|
||||||
if (siteSettings.Value.Blog == null)
|
if (siteSettings.Value.UserFilesRoot == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ public class BlogSpotService
|
||||||
{
|
{
|
||||||
string blogFilesSubdir = $"blogs/{postId}";
|
string blogFilesSubdir = $"blogs/{postId}";
|
||||||
string destDir = Path.Combine(
|
string destDir = Path.Combine(
|
||||||
siteSettings.Blog,
|
siteSettings.UserFilesRoot,
|
||||||
user.UserName,
|
user.UserName,
|
||||||
blogFilesSubdir);
|
blogFilesSubdir);
|
||||||
var di = new DirectoryInfo(destDir);
|
var di = new DirectoryInfo(destDir);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Yavsc.Models.Google.Messaging;
|
||||||
using Yavsc.Models.Haircut;
|
using Yavsc.Models.Haircut;
|
||||||
using Yavsc.Models.Messaging;
|
using Yavsc.Models.Messaging;
|
||||||
using Yavsc.Server.Hubs;
|
using Yavsc.Server.Hubs;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
|
||||||
namespace Yavsc.Services
|
namespace Yavsc.Services
|
||||||
{
|
{
|
||||||
|
|
@ -30,7 +31,7 @@ namespace Yavsc.Services
|
||||||
IHubContext<ChatHub> hubContext
|
IHubContext<ChatHub> hubContext
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<MailSender>();
|
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
siteSettings = sitesOptions?.Value;
|
siteSettings = sitesOptions?.Value;
|
||||||
this.hubContext = hubContext;
|
this.hubContext = hubContext;
|
||||||
|
|
@ -70,34 +71,51 @@ namespace Yavsc.Services
|
||||||
};
|
};
|
||||||
|
|
||||||
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
||||||
|
results.Add(result);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
response.failure++;
|
response.failure++;
|
||||||
result.error = "no such user.";
|
result.error = "no such user.";
|
||||||
continue;
|
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 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,
|
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||||
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
subject,
|
||||||
body + Environment.NewLine);
|
body + Environment.NewLine);
|
||||||
response.success++;
|
response.success++;
|
||||||
|
}
|
||||||
|
|
||||||
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
||||||
if (cxIds == null)
|
if (cxIds == null)
|
||||||
|
|
@ -125,7 +143,6 @@ namespace Yavsc.Services
|
||||||
|
|
||||||
response.success++;
|
response.success++;
|
||||||
}
|
}
|
||||||
results.Add(result);
|
|
||||||
}
|
}
|
||||||
response.results = results.ToArray();
|
response.results = results.ToArray();
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ namespace Yavsc
|
||||||
public string DataDir { get; set; }
|
public string DataDir { get; set; }
|
||||||
public string Avatars { get; set; }
|
public string Avatars { get; set; }
|
||||||
public long Quota { get; set; }
|
public long Quota { get; set; }
|
||||||
public string Blog { get; set; }
|
public string UserFilesRoot { get; set; }
|
||||||
public string Bills { get; set; }
|
public string Bills { get; set; }
|
||||||
public string GitRepository { get; set; }
|
public string GitRepository { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Yavsc.Interface;
|
||||||
|
using Yavsc.Server.Hubs;
|
||||||
|
|
||||||
namespace Yavsc.Api.Test.Fixtures;
|
namespace Yavsc.Api.Test.Fixtures;
|
||||||
|
|
||||||
|
|
@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
|
|
||||||
builder.Services.AddLocalization();
|
builder.Services.AddLocalization();
|
||||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||||
|
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
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 =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AdministratorOnly", policy =>
|
options.AddPolicy("AdministratorOnly", policy =>
|
||||||
|
|
@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
return builder.ToString();
|
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()
|
public void ResetAndSeedActivityGraph()
|
||||||
{
|
{
|
||||||
|
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
|
||||||
using var scope = Services.CreateScope();
|
using var scope = Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
|
@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
// CommandLine reference NominativeServiceCommand (CommandId) and
|
// CommandLine reference NominativeServiceCommand (CommandId) and
|
||||||
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
||||||
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
|
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<Estimate>().RemoveRange(db.Set<Estimate>());
|
||||||
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
||||||
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
using Yavsc.Services;
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
|
@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
|
|
||||||
// Default caller is "alice" (the provider) → ProAccepted.
|
// Default caller is "alice" (the provider) → ProAccepted.
|
||||||
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
||||||
|
Assert.Empty(assertDb.Notification);
|
||||||
|
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||||
Assert.Equal("alice", signature.SignerId);
|
Assert.Equal("alice", signature.SignerId);
|
||||||
|
Assert.Empty(assertDb.Notification);
|
||||||
|
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
Assert.Equal(SignatureType.Client, signature.Type);
|
Assert.Equal(SignatureType.Client, signature.Type);
|
||||||
Assert.Equal("bob", signature.SignerId);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ using Yavsc;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Services;
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
|
@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
return http;
|
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]
|
[Fact]
|
||||||
public async Task Billing_rdv_route_supports_crud()
|
public async Task Billing_rdv_route_supports_crud()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
using Yavsc.Abstract.Resources;
|
using Yavsc.Abstract.Resources;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -98,6 +99,66 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
|
||||||
Assert.True(record.BandwidthMb >= 3.5m);
|
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]
|
[Fact]
|
||||||
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
// on real files.
|
// on real files.
|
||||||
builder.Services.Configure<SiteSettings>(s =>
|
builder.Services.Configure<SiteSettings>(s =>
|
||||||
{
|
{
|
||||||
s.Blog = BlogFilesRoot;
|
s.UserFilesRoot = BlogFilesRoot;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Real BlogSpotService — same instance the production host
|
// Real BlogSpotService — same instance the production host
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
||||||
// BlogsWebServerFixture.
|
// BlogsWebServerFixture.
|
||||||
builder.Services.Configure<SiteSettings>(s =>
|
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<BlogSpotService>();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
"FavIcon": "/favicon.ico",
|
"FavIcon": "/favicon.ico",
|
||||||
"Icon": "/images/yavsc.png",
|
"Icon": "/images/yavsc.png",
|
||||||
"GitRepository": "testingrepo",
|
"GitRepository": "testingrepo",
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Owner": {
|
"Owner": {
|
||||||
"Name": "Site Owner Name",
|
"Name": "Site Owner Name",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ public sealed class ServerServicesFixture : IDisposable
|
||||||
// Details itself doesn't read it, but the service constructor
|
// Details itself doesn't read it, but the service constructor
|
||||||
// throws if SiteSettings.Blog is null, so configure a temp
|
// throws if SiteSettings.Blog is null, so configure a temp
|
||||||
// value to keep construction alive.
|
// 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
|
// Trivial file-system auth: Details never calls into it, but
|
||||||
// the DI container needs an instance to construct the service.
|
// 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,
|
public void SetAccess(long circleId, string normalizedFullPath,
|
||||||
FileAccessRight access) { }
|
FileAccessRight access) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue