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

@ -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
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// 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.
/// </summary>
public class SignatureSubmission
{

View file

@ -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> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{
dbContext = context;
this.messageSender = messageSender;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
@ -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,

View file

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