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()
{