diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b897b724..af5479790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### 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. diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index aa195712d..5f7f8e057 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings); estimate.ClientValidationDate = DateTime.UtcNow; dbContext.SaveChanges(User.GetUserId()); + var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName); + await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent); return Ok (new { ClientValidationDate = estimate.ClientValidationDate }); } @@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers .FirstOrDefaultAsync(e => e.Id == id, token); if (estimate is null) return NotFound(new { Error = "estimate not found" }); - // The signer is identified by userId in the body, not - // by the bearer token, because the OAuth scope we - // carry is for the API client (PostIt), not the end - // user. We trust the body's userId to match either - // Owner or Client, and reject everything else. var userId = body.SignerUserId; + if (userId != User.GetUserId()) + return Forbid(); if (userId != estimate.OwnerId && userId != estimate.ClientId) return Forbid(); @@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers var signature = await EstimateSignaturePersister.StageAsync( dbContext, id, type, userId, payload, token); + if (type == SignatureType.Client) + estimate.ClientValidationDate = DateTime.UtcNow; + try { await dbContext.SaveChangesAsync(token); @@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers return BadRequest(new { Error = "db write failed", Detail = ex.Message }); } + if (type == SignatureType.Client) + { + var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName); + await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent); + } + var location = Url.Action(nameof(Sign), new { id }) ?? $"/api/bill/estimate/{id}/sign"; return Created(location, new @@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers /// /// JSON body of POST /api/bill/estimate/{id}/sign. The /// shape mirrors what PostIt sends; the signerUserId -/// field disambiguates which side of the estimate signed -/// because the bearer token belongs to the PostIt OAuth -/// client, not the end user. +/// field must match the authenticated caller and identifies +/// which party of the estimate signed. /// public class SignatureSubmission { diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index 9da7d0a94..7ce2e14df 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -1,3 +1,5 @@ +#nullable enable annotations + using System.Diagnostics; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,6 +12,7 @@ using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Billing; using Yavsc.Models.Blog; +using Yavsc.Models.Messaging; using Yavsc.Services; using Yavsc.Server.Helpers; using Yavsc.ViewModels.FrontOffice; @@ -26,6 +29,7 @@ namespace Yavsc.ApiControllers private readonly ILogger logger; private readonly SiteSettings siteSettings; private readonly IRazorViewEngine viewEngine; + private readonly IYavscMessageSender messageSender; // The view-rendering dependencies (siteSettings, viewEngine) are // optional: only EstimateTex/EstimatePdf use them, and those run in @@ -45,11 +49,13 @@ namespace Yavsc.ApiControllers public FrontOfficeApiController( ApplicationDbContext context, ILoggerFactory loggerFactory, + IYavscMessageSender messageSender, IOptions siteSettings = null, IRazorViewEngine viewEngine = null, IBillingService billing = null) { dbContext = context; + this.messageSender = messageSender; this.billing = billing ?? new BillingService(context); logger = loggerFactory.CreateLogger(); this.siteSettings = siteSettings?.Value; @@ -233,6 +239,7 @@ namespace Yavsc.ApiControllers if (query is null) return BadRequest(new { Error = "query not found" }); Signature? signature = null; + ClientEstimateSignedEvent? signedEvent = null; // actingRole is the role the caller is signing / accepting // as. It defaults to the role inferred from the caller's // identity, but when a signature is submitted the caller @@ -299,7 +306,11 @@ namespace Yavsc.ApiControllers if (actingRole == ActorRole.Provider) estimate.ProviderValidationDate = DateTime.UtcNow; else + { estimate.ClientValidationDate = DateTime.UtcNow; + var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token); + signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName); + } } // The party-specific status records who accepted: the @@ -323,6 +334,9 @@ namespace Yavsc.ApiControllers return BadRequest(new { Error = "db write failed", Detail = ex.Message }); } + if (signedEvent is not null) + await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent); + return Ok(new { queryId, diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index 06256a987..6ecf89dd7 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -10,6 +10,8 @@ using Yavsc.Models.Billing; using Yavsc.Models.Relationship; using Yavsc.Models.Workflow; using Yavsc.Server.Helpers; +using Yavsc.Helpers; +using Yavsc.Services; namespace Yavsc.Controllers; @@ -19,10 +21,12 @@ namespace Yavsc.Controllers; public class RdvQueryApiController : Controller { private readonly ApplicationDbContext _context; + private readonly IYavscMessageSender _messageSender; - public RdvQueryApiController(ApplicationDbContext context) + public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender) { _context = context; + _messageSender = messageSender; } [HttpGet] @@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller return BadRequest(new { Error = "location is required" }); } + var performer = await _context.Performers + .AsNoTracking() + .SingleOrDefaultAsync(p => p.PerformerId == query.PerformerId, cancellationToken); + if (performer is null) + return BadRequest(new { Error = "performer not found" }); + var client = await _context.Users.SingleOrDefaultAsync(u => u.Id == uid, cancellationToken); + if (client is null) + return Unauthorized(); + query.Client = client; + var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken); if (resolvedLocation is null) { @@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller throw; } + if (performer.AcceptNotifications && performer.AcceptPublicContact) + await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand")); + return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query); } diff --git a/src/Yavsc.Org/Helpers/EventHelpers.cs b/src/Yavsc.Org/Helpers/EventHelpers.cs index 151f3360d..b40a3c124 100644 --- a/src/Yavsc.Org/Helpers/EventHelpers.cs +++ b/src/Yavsc.Org/Helpers/EventHelpers.cs @@ -11,28 +11,6 @@ namespace Yavsc.Helpers public static class EventHelpers { - public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic) - { - var yaev = new RdvQueryEvent(subtopic) - { - Sender = query.ClientId, - Reason = query.Reason, - Client = new ClientProviderInfo { - UserName = query.Client.UserName , - UserId = query.ClientId, - Avatar = query.Client.Avatar } , - Previsional = query.Provisional, - EventDate = query.EventDate, - Location = query.Location, - Id = query.Id, - ActivityCode = query.ActivityCode, - BillingCode = BillingCodes.Rdv - }; - - return yaev; - } - - public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query, IStringLocalizer SR) { diff --git a/src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs b/src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs new file mode 100644 index 000000000..0c8051ffc --- /dev/null +++ b/src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs @@ -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 + }; +} diff --git a/src/Yavsc.Server/Models/Messaging/ClientEstimateSignedEvent.cs b/src/Yavsc.Server/Models/Messaging/ClientEstimateSignedEvent.cs new file mode 100644 index 000000000..9698a5d7d --- /dev/null +++ b/src/Yavsc.Server/Models/Messaging/ClientEstimateSignedEvent.cs @@ -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}."; +} diff --git a/src/Yavsc.Server/Services/YavscMessageSender.cs b/src/Yavsc.Server/Services/YavscMessageSender.cs index 3ab43fbee..1b16c91f1 100644 --- a/src/Yavsc.Server/Services/YavscMessageSender.cs +++ b/src/Yavsc.Server/Services/YavscMessageSender.cs @@ -9,6 +9,7 @@ using Yavsc.Models.Google.Messaging; using Yavsc.Models.Haircut; using Yavsc.Models.Messaging; using Yavsc.Server.Hubs; +using Yavsc.Abstract.Models.Messaging; namespace Yavsc.Services { @@ -30,7 +31,7 @@ namespace Yavsc.Services IHubContext hubContext ) { - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); _emailSender = emailSender; siteSettings = sitesOptions?.Value; this.hubContext = hubContext; @@ -70,34 +71,51 @@ namespace Yavsc.Services }; var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId); + results.Add(result); if (user == null) { response.failure++; result.error = "no such user."; continue; } - if (!user.EmailConfirmed) - { - response.failure++; - result.error = "user has not confirmed his email address."; - continue; - } - if (user.Email == null) - { - response.failure++; - result.error = "user has no legacy email address."; - continue; - } - var body = ev.CreateBody(); + var subject = ev is ClientEstimateSignedEvent signed + ? signed.Title + : $"{ev.Sender} (un client) vous demande un rendez-vous"; + if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent) + { + _dbContext.Notification.Add(new Notification + { + title = subject.Length > 1024 ? subject[..1024] : subject, + body = body.Length > 512 ? body[..512] : body, + Target = $"user/{userId}", + tag = ev switch + { + ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed", + RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}", + _ => throw new InvalidOperationException("Unsupported persistent notification event.") + }, + click_action = "Fermer" + }); + await _dbContext.SaveChangesAsync(ev.Sender); + } - _logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}"); - + if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email)) + { + response.failure++; + result.error = !user.EmailConfirmed + ? "user has not confirmed his email address." + : "user has no legacy email address."; + _logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error); + } + else + { result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email, - $"{ev.Sender} (un client) vous demande un rendez-vous", + subject, body + Environment.NewLine); response.success++; + } var cxIds = _cxManager.GetConnexionIds(user.UserName); if (cxIds == null) @@ -125,7 +143,6 @@ namespace Yavsc.Services response.success++; } - results.Add(result); } response.results = results.ToArray(); return response; diff --git a/src/test/Yavsc.Api.Tests/BillingSignatureNotificationTests.cs b/src/test/Yavsc.Api.Tests/BillingSignatureNotificationTests.cs new file mode 100644 index 000000000..8858c4d53 --- /dev/null +++ b/src/test/Yavsc.Api.Tests/BillingSignatureNotificationTests.cs @@ -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 +{ + [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(); + var estimate = new Estimate + { + ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv, + Title = "Devis notification", Description = "Devis de test", + AttachedFiles = new List(), AttachedGraphics = new List() + }; + db.Estimates.Add(estimate); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + var controller = ActivatorUtilities.CreateInstance(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(result); + Assert.Empty(db.Signatures); + } + else + Assert.IsType(result); + + var delivery = fixture.Services.GetRequiredService(); + 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(); + } +} diff --git a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs index 875efb249..3af71d909 100644 --- a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs +++ b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs @@ -16,6 +16,9 @@ using Yavsc.Models.Relationship; using Yavsc.Models.Workflow; using Yavsc.Services; using Yavsc.Tests.Shared; +using Microsoft.AspNetCore.SignalR; +using Yavsc.Interface; +using Yavsc.Server.Hubs; namespace Yavsc.Api.Test.Fixtures; @@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture builder.Services.AddLocalization(); builder.Services.Configure(_ => { }); + builder.Services.Configure(settings => settings.Authority = "example.test"); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton>(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddAuthorization(options => { options.AddPolicy("AdministratorOnly", policy => @@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture return builder.ToString(); } - private sealed class NoopMessageSender : IYavscMessageSender - { - public Task NotifyBookQueryAsync(IEnumerable connectionIds, RdvQueryEvent ev) - => Task.FromResult(new MessageWithPayloadResponse()); - - public Task NotifyEstimateAsync(IEnumerable connectionIds, EstimationEvent ev) - => Task.FromResult(new MessageWithPayloadResponse()); - - public Task NotifyHairCutQueryAsync(IEnumerable connectionIds, HairCutQueryEvent ev) - => Task.FromResult(new MessageWithPayloadResponse()); - - public Task NotifyAsync(IEnumerable connectionIds, IEvent yaev) - => Task.FromResult(new MessageWithPayloadResponse()); - } - public void ResetAndSeedActivityGraph() { + Services.GetRequiredService().Clear(); using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture // CommandLine reference NominativeServiceCommand (CommandId) and // must be deleted before the Rdv/HairCut/HairMultiCut queries. db.Set().RemoveRange(db.Set()); + db.DismissClicked.RemoveRange(db.DismissClicked); + db.Notification.RemoveRange(db.Notification); db.Set().RemoveRange(db.Set()); db.Set().RemoveRange(db.Set()); db.Set().RemoveRange(db.Set()); diff --git a/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs b/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs new file mode 100644 index 000000000..e26bd4f9a --- /dev/null +++ b/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs @@ -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, IHubClients +{ + public record Email(string Recipient, string Subject, string Body); + public record Push(string ConnectionId, string Method, object?[] Arguments); + + public ConcurrentQueue Emails { get; } = new(); + public ConcurrentQueue Pushes { get; } = new(); + IHubClients IHubContext.Clients => this; + IGroupManager IHubContext.Groups => throw new NotSupportedException(); + public IClientProxy All => throw new NotSupportedException(); + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId); + public IClientProxy Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IClientProxy Group(string groupName) => throw new NotSupportedException(); + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); + public IClientProxy Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); + public IClientProxy User(string userId) => throw new NotSupportedException(); + public IClientProxy Users(IReadOnlyList userIds) => throw new NotSupportedException(); + + public Task 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; + } + } +} diff --git a/src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs b/src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs index b2a8fdbee..14880bb7d 100644 --- a/src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/FrontOfficeApiControllerTests.cs @@ -10,6 +10,7 @@ using Yavsc.Models; using Yavsc.Models.Billing; using Yavsc.Models.Workflow; using Yavsc.Tests.Shared; +using Yavsc.Services; namespace Yavsc.Api.Test; @@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture().Emails); } [Fact] @@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture s.EstimateId == estimateId); Assert.Equal(SignatureType.Pro, signature.Type); Assert.Equal("alice", signature.SignerId); + Assert.Empty(assertDb.Notification); + Assert.Empty(_fixture.Services.GetRequiredService().Emails); } [Fact] @@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture s.EstimateId == estimateId); Assert.Equal(SignatureType.Client, signature.Type); Assert.Equal("bob", signature.SignerId); + var notification = Assert.Single(assertDb.Notification); + Assert.Equal("user/alice", notification.Target); + Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag); + Assert.Contains("bob", notification.body); + Assert.Contains("Devis prestation", notification.body); + var email = Assert.Single(_fixture.Services.GetRequiredService().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(); + db.Users.Single(u => u.Id == "alice").EmailConfirmed = false; + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + var manager = _fixture.Services.GetRequiredService(); + 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(); + 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(); + Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag); + } + finally + { + manager.OnDisconnected("signed-alice"); + } } [Fact] diff --git a/src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs b/src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs index c3e4aad2b..f14b50aad 100644 --- a/src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs +++ b/src/test/Yavsc.Api.Tests/RdvQueryApiControllerTests.cs @@ -5,6 +5,9 @@ using Yavsc; using Yavsc.Api.Test.Fixtures; using Yavsc.Models.Workflow; using Yavsc.Tests.Shared; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Models; +using Yavsc.Services; namespace Yavsc.Api.Test; @@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(); + 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(TestContext.Current.CancellationToken); + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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(); + 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(); + 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(); + Assert.Empty(assertDb.Notification); + Assert.Empty(_fixture.Services.GetRequiredService().Emails); + } + [Fact] public async Task Billing_rdv_route_supports_crud() {