Notify performers of API bookings and client-signed estimates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-11 00:14:33 +01:00
commit f9d95fd98f
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
13 changed files with 390 additions and 63 deletions

View file

@ -4,6 +4,11 @@
### Added ### 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` * [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées. Home page ; les notifications d'administration restent filtrées.

View file

@ -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
{ {

View file

@ -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,

View file

@ -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);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow; using Yavsc.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>());

View file

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

View file

@ -10,6 +10,7 @@ using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.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]

View file

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