2023-03-19 17:57:55 +00:00
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2019-08-21 17:23:58 +01:00
|
|
|
|
using Microsoft.Extensions.Localization;
|
|
|
|
|
|
using Newtonsoft.Json;
|
|
|
|
|
|
using System.Security.Claims;
|
2026-09-12 13:29:49 +01:00
|
|
|
|
using Yavsc.Billing;
|
2019-08-21 17:23:58 +01:00
|
|
|
|
using Yavsc.Helpers;
|
|
|
|
|
|
using Yavsc.ViewModels;
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
using Yavsc.Models.Billing;
|
2026-09-13 03:09:12 +01:00
|
|
|
|
using Yavsc.Models.Haircut;
|
|
|
|
|
|
using Yavsc.Models.Workflow;
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
using Yavsc.Server.Models.FileSystem;
|
2017-03-10 00:27:53 +01:00
|
|
|
|
|
|
|
|
|
|
namespace Yavsc.ApiControllers
|
|
|
|
|
|
{
|
|
|
|
|
|
using Models;
|
|
|
|
|
|
using Services;
|
2016-11-09 14:03:57 +01:00
|
|
|
|
|
2017-03-10 00:27:53 +01:00
|
|
|
|
using Models.Messaging;
|
2023-03-19 17:57:55 +00:00
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
2025-02-14 00:20:35 +00:00
|
|
|
|
using Yavsc.ViewModels.Auth;
|
2025-02-17 23:56:28 +00:00
|
|
|
|
using Yavsc.Server.Helpers;
|
2017-06-08 00:26:29 +02:00
|
|
|
|
|
2026-08-30 23:55:09 +01:00
|
|
|
|
[Authorize]
|
2026-08-20 20:50:52 +01:00
|
|
|
|
[Route(Constants.APIPrefix + "/bill"), Authorize]
|
2017-06-08 00:26:29 +02:00
|
|
|
|
public class BillingController : Controller
|
2016-11-07 19:34:56 +01:00
|
|
|
|
{
|
2020-10-09 19:35:39 +01:00
|
|
|
|
readonly ApplicationDbContext dbContext;
|
|
|
|
|
|
private readonly IStringLocalizer _localizer;
|
|
|
|
|
|
private readonly GoogleAuthSettings _googleSettings;
|
|
|
|
|
|
private readonly IYavscMessageSender _GCMSender;
|
|
|
|
|
|
private readonly IAuthorizationService authorizationService;
|
2016-11-09 14:03:57 +01:00
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
|
2020-10-09 19:35:39 +01:00
|
|
|
|
private readonly ILogger logger;
|
|
|
|
|
|
private readonly IBillingService billingService;
|
2026-09-17 22:22:18 +01:00
|
|
|
|
private readonly SiteSettings siteSettings;
|
2016-11-09 14:03:57 +01:00
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
public BillingController(
|
2017-01-13 16:31:40 +01:00
|
|
|
|
IAuthorizationService authorizationService,
|
|
|
|
|
|
ILoggerFactory loggerFactory,
|
2025-08-31 18:27:53 +01:00
|
|
|
|
IStringLocalizer<BillingController> SR,
|
2017-03-05 20:29:02 +01:00
|
|
|
|
ApplicationDbContext context,
|
|
|
|
|
|
IOptions<GoogleAuthSettings> googleSettings,
|
2019-05-08 01:35:10 +01:00
|
|
|
|
IYavscMessageSender GCMSender,
|
2026-09-17 22:22:18 +01:00
|
|
|
|
IBillingService billingService,
|
|
|
|
|
|
IOptions<SiteSettings> siteSettings
|
2017-03-05 20:29:02 +01:00
|
|
|
|
)
|
2016-11-09 14:03:57 +01:00
|
|
|
|
{
|
2017-03-05 20:29:02 +01:00
|
|
|
|
_googleSettings=googleSettings.Value;
|
2017-01-13 16:31:40 +01:00
|
|
|
|
this.authorizationService = authorizationService;
|
2016-11-09 14:03:57 +01:00
|
|
|
|
dbContext = context;
|
2017-06-08 00:26:29 +02:00
|
|
|
|
logger = loggerFactory.CreateLogger<BillingController>();
|
2017-02-22 22:56:03 +01:00
|
|
|
|
this._localizer = SR;
|
2017-03-05 20:29:02 +01:00
|
|
|
|
_GCMSender=GCMSender;
|
2017-06-08 00:26:29 +02:00
|
|
|
|
this.billingService=billingService;
|
2026-09-17 22:22:18 +01:00
|
|
|
|
this.siteSettings = siteSettings.Value;
|
2016-11-09 14:03:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-08 11:05:57 +02:00
|
|
|
|
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
|
2017-06-08 00:26:29 +02:00
|
|
|
|
public async Task<IActionResult> GetPdf(string billingCode, long id)
|
2026-07-11 03:26:13 +01:00
|
|
|
|
{
|
2017-06-08 00:26:29 +02:00
|
|
|
|
var bill = await billingService.GetBillAsync(billingCode, id);
|
|
|
|
|
|
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if ( authorizationService.AuthorizeAsync(User, bill, new ReadPermission()).IsFaulted)
|
2016-11-07 19:34:56 +01:00
|
|
|
|
{
|
2017-01-13 16:31:40 +01:00
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2026-09-17 22:22:18 +01:00
|
|
|
|
var fi = bill.GetBillInfo(billingService, siteSettings);
|
2017-07-08 03:23:21 +02:00
|
|
|
|
|
2016-11-09 14:03:57 +01:00
|
|
|
|
if (!fi.Exists) return Ok(new { Error = "Not generated" });
|
2026-07-11 03:26:13 +01:00
|
|
|
|
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
|
2016-11-09 14:03:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-08 11:05:57 +02:00
|
|
|
|
[HttpGet("facture-{billingCode}-{id}.tex"), Authorize]
|
2017-06-08 00:26:29 +02:00
|
|
|
|
public async Task<IActionResult> GetTex(string billingCode, long id)
|
2016-11-09 14:03:57 +01:00
|
|
|
|
{
|
2017-06-08 00:26:29 +02:00
|
|
|
|
var bill = await billingService.GetBillAsync(billingCode, id);
|
|
|
|
|
|
|
2017-06-08 11:05:57 +02:00
|
|
|
|
if (bill==null) {
|
2017-06-08 17:09:06 +02:00
|
|
|
|
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
|
2023-03-19 17:57:55 +00:00
|
|
|
|
return this.NotFound();
|
2017-06-08 11:05:57 +02:00
|
|
|
|
}
|
2023-03-19 17:57:55 +00:00
|
|
|
|
logger.LogTrace(JsonConvert.SerializeObject(bill));
|
2017-06-08 00:26:29 +02:00
|
|
|
|
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if (!(await authorizationService.AuthorizeAsync(User, bill, new ReadPermission())).Succeeded)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
2016-11-09 14:03:57 +01:00
|
|
|
|
Response.ContentType = "text/x-tex";
|
2017-07-08 03:23:21 +02:00
|
|
|
|
return ViewComponent("Bill",new object[] { billingCode, bill , OutputFormat.LaTeX, true });
|
2016-11-09 14:03:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
[HttpPost("genpdf/{billingCode}/{id}")]
|
|
|
|
|
|
public async Task<IActionResult> GeneratePdf(string billingCode, long id)
|
2016-11-09 14:03:57 +01:00
|
|
|
|
{
|
2017-06-08 17:09:06 +02:00
|
|
|
|
var bill = await billingService.GetBillAsync(billingCode, id);
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2017-06-08 17:09:06 +02:00
|
|
|
|
if (bill==null) {
|
|
|
|
|
|
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
|
2023-03-19 17:57:55 +00:00
|
|
|
|
return this.NotFound();
|
2017-01-13 16:31:40 +01:00
|
|
|
|
}
|
2017-07-08 03:23:21 +02:00
|
|
|
|
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
|
|
|
|
|
|
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
|
2016-11-07 19:34:56 +01:00
|
|
|
|
}
|
2017-01-13 16:31:40 +01:00
|
|
|
|
|
2026-09-12 13:29:49 +01:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Lists ongoing service commands for the authenticated performer.
|
|
|
|
|
|
/// This endpoint is tailored for the PostIt provider homepage flow
|
|
|
|
|
|
/// ("Mes demandes en cours").
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[HttpGet("provider/ongoing")]
|
|
|
|
|
|
[Produces("application/json")]
|
|
|
|
|
|
public IActionResult GetProviderOngoingCommands()
|
|
|
|
|
|
{
|
|
|
|
|
|
var uid = User.GetUserId();
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(uid))
|
|
|
|
|
|
{
|
|
|
|
|
|
return Unauthorized();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (billingService.BillingMap.Count == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
WorkflowHelpers.ConfigureBillingService();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 03:49:07 +01:00
|
|
|
|
var allowedActivityCodes = dbContext.UserActivities
|
2026-09-12 13:29:49 +01:00
|
|
|
|
.AsNoTracking()
|
2026-09-13 03:49:07 +01:00
|
|
|
|
.Where(a => a.UserId == uid)
|
|
|
|
|
|
.Select(a => a.DoesCode)
|
|
|
|
|
|
.Distinct()
|
2026-09-13 03:09:12 +01:00
|
|
|
|
.ToList();
|
|
|
|
|
|
|
2026-09-13 03:49:07 +01:00
|
|
|
|
if (allowedActivityCodes.Count == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Ok(Array.Empty<object>());
|
|
|
|
|
|
}
|
2026-09-13 03:09:12 +01:00
|
|
|
|
|
2026-09-13 03:49:07 +01:00
|
|
|
|
var allowedBillingCodes = dbContext.CommandForm
|
2026-09-13 03:09:12 +01:00
|
|
|
|
.AsNoTracking()
|
2026-09-13 03:49:07 +01:00
|
|
|
|
.Where(form => allowedActivityCodes.Contains(form.ActivityCode))
|
|
|
|
|
|
.Select(form => form.ActionName)
|
|
|
|
|
|
.Where(actionName => !string.IsNullOrWhiteSpace(actionName))
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
|
|
var fallbackToActivityFilteringOnly = allowedBillingCodes.Count == 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Query only the command types allowed by the performer's declared
|
|
|
|
|
|
// activities; this avoids touching unrelated legacy slices.
|
|
|
|
|
|
var rdvCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Rdv)
|
|
|
|
|
|
? dbContext.Set<RdvQuery>()
|
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
|
.Where(q => q.PerformerId == uid)
|
|
|
|
|
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
|
|
|
|
|
.Where(q => q.Status == QueryStatus.Inserted
|
|
|
|
|
|
|| q.Status == QueryStatus.Accepted
|
2026-09-21 17:33:32 +01:00
|
|
|
|
|| q.Status == QueryStatus.ProAccepted
|
|
|
|
|
|
|| q.Status == QueryStatus.ClientAccepted
|
2026-09-13 03:49:07 +01:00
|
|
|
|
|| q.Status == QueryStatus.InProgress)
|
|
|
|
|
|
.Cast<NominativeServiceCommand>()
|
|
|
|
|
|
.ToList()
|
|
|
|
|
|
: new List<NominativeServiceCommand>();
|
|
|
|
|
|
|
|
|
|
|
|
var hairCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Brush)
|
|
|
|
|
|
? dbContext.Set<HairCutQuery>()
|
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
|
.Where(q => q.PerformerId == uid)
|
|
|
|
|
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
|
|
|
|
|
.Where(q => q.Status == QueryStatus.Inserted
|
|
|
|
|
|
|| q.Status == QueryStatus.Accepted
|
2026-09-21 17:33:32 +01:00
|
|
|
|
|| q.Status == QueryStatus.ProAccepted
|
|
|
|
|
|
|| q.Status == QueryStatus.ClientAccepted
|
2026-09-13 03:49:07 +01:00
|
|
|
|
|| q.Status == QueryStatus.InProgress)
|
|
|
|
|
|
.Cast<NominativeServiceCommand>()
|
|
|
|
|
|
.ToList()
|
|
|
|
|
|
: new List<NominativeServiceCommand>();
|
|
|
|
|
|
|
|
|
|
|
|
var hairMultiCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.MBrush)
|
|
|
|
|
|
? dbContext.Set<HairMultiCutQuery>()
|
|
|
|
|
|
.AsNoTracking()
|
|
|
|
|
|
.Where(q => q.PerformerId == uid)
|
|
|
|
|
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
|
|
|
|
|
.Where(q => q.Status == QueryStatus.Inserted
|
|
|
|
|
|
|| q.Status == QueryStatus.Accepted
|
2026-09-21 17:33:32 +01:00
|
|
|
|
|| q.Status == QueryStatus.ProAccepted
|
|
|
|
|
|
|| q.Status == QueryStatus.ClientAccepted
|
2026-09-13 03:49:07 +01:00
|
|
|
|
|| q.Status == QueryStatus.InProgress)
|
|
|
|
|
|
.Cast<NominativeServiceCommand>()
|
|
|
|
|
|
.ToList()
|
|
|
|
|
|
: new List<NominativeServiceCommand>();
|
2026-09-13 03:09:12 +01:00
|
|
|
|
|
|
|
|
|
|
var commands = rdvCommands
|
|
|
|
|
|
.Concat(hairCommands)
|
|
|
|
|
|
.Concat(hairMultiCommands)
|
2026-09-12 13:29:49 +01:00
|
|
|
|
.OrderByDescending(q => q.DateModified)
|
|
|
|
|
|
.ThenByDescending(q => q.Id)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
var payload = commands
|
|
|
|
|
|
.Select(q => new
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = q.Id,
|
|
|
|
|
|
BillingCode = ResolveBillingCode(q),
|
|
|
|
|
|
ActivityCode = q.ActivityCode,
|
|
|
|
|
|
PerformerId = q.PerformerId,
|
|
|
|
|
|
ClientId = q.ClientId,
|
|
|
|
|
|
Status = q.Status,
|
|
|
|
|
|
Description = q.Description,
|
|
|
|
|
|
EventDate = ResolveEventDate(q),
|
|
|
|
|
|
Reason = q is Models.Workflow.RdvQuery rdv ? rdv.Reason : string.Empty,
|
|
|
|
|
|
AdditionalInfo = q is Models.Haircut.HairCutQuery hc ? hc.AdditionalInfo : string.Empty,
|
|
|
|
|
|
Provisional = q.Provisional,
|
|
|
|
|
|
})
|
|
|
|
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.BillingCode))
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
return Ok(payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2017-01-13 16:31:40 +01:00
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
[HttpPost("prosign/{billingCode}/{id}")]
|
|
|
|
|
|
public async Task<IActionResult> ProSign(string billingCode, long id)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
2017-02-22 22:56:03 +01:00
|
|
|
|
var estimate = dbContext.Estimates.
|
2019-05-24 20:17:24 +01:00
|
|
|
|
Include(e=>e.Client).Include(e=>e.Client.DeviceDeclaration)
|
2017-02-22 22:56:03 +01:00
|
|
|
|
.Include(e=>e.Bill).Include(e=>e.Owner).Include(e=>e.Owner.Performer)
|
|
|
|
|
|
.FirstOrDefault(e=>e.Id == id);
|
|
|
|
|
|
if (estimate == null)
|
|
|
|
|
|
return new BadRequestResult();
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
|
2023-03-19 17:57:55 +00:00
|
|
|
|
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Request.Form.Files.Count!=1)
|
|
|
|
|
|
return new BadRequestResult();
|
2026-09-17 22:22:18 +01:00
|
|
|
|
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro", siteSettings);
|
2026-07-11 03:26:13 +01:00
|
|
|
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
2017-02-23 03:10:30 +01:00
|
|
|
|
dbContext.SaveChanges(User.GetUserId());
|
2017-01-13 16:31:40 +01:00
|
|
|
|
// Notify the client
|
2017-02-22 22:56:03 +01:00
|
|
|
|
var locstr = _localizer["EstimationMessageToClient"];
|
|
|
|
|
|
|
2018-05-15 14:07:27 +02:00
|
|
|
|
var yaev = new EstimationEvent(estimate,_localizer);
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2019-05-24 20:17:24 +01:00
|
|
|
|
var regids = new [] { estimate.Client.Id };
|
2017-03-03 02:12:42 +01:00
|
|
|
|
bool gcmSent = false;
|
2018-05-04 10:45:45 +02:00
|
|
|
|
var grep = await _GCMSender.NotifyEstimateAsync(regids,yaev);
|
2017-03-03 02:12:42 +01:00
|
|
|
|
gcmSent = grep.success>0;
|
|
|
|
|
|
return Ok (new { ProviderValidationDate = estimate.ProviderValidationDate, GCMSent = gcmSent });
|
2017-01-13 16:31:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 13:29:49 +01:00
|
|
|
|
private string ResolveBillingCode(NominativeServiceCommand command)
|
|
|
|
|
|
{
|
|
|
|
|
|
var typeName = command.GetType().Name;
|
|
|
|
|
|
return billingService.BillingMap.TryGetValue(typeName, out var code)
|
|
|
|
|
|
? code
|
|
|
|
|
|
: string.Empty;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static DateTime? ResolveEventDate(NominativeServiceCommand command)
|
|
|
|
|
|
=> command switch
|
|
|
|
|
|
{
|
|
|
|
|
|
Models.Workflow.RdvQuery rdv => rdv.EventDate,
|
|
|
|
|
|
Models.Haircut.HairCutQuery brush => brush.EventDate,
|
|
|
|
|
|
Models.Haircut.HairMultiCutQuery mbrush => mbrush.EventDate,
|
|
|
|
|
|
_ => null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
[HttpGet("prosign/{billingCode}/{id}")]
|
|
|
|
|
|
public async Task<IActionResult> GetProSign(string billingCode, long id)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
// For authorization purpose
|
|
|
|
|
|
var estimate = dbContext.Estimates.FirstOrDefault(e=>e.Id == id);
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2019-08-21 17:23:58 +01:00
|
|
|
|
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
2026-09-17 22:22:18 +01:00
|
|
|
|
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
2023-03-19 17:57:55 +00:00
|
|
|
|
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
2017-01-13 16:31:40 +01:00
|
|
|
|
return File(fi.OpenRead(), "application/x-pdf", filename); ;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
[HttpPost("clisign/{billingCode}/{id}")]
|
|
|
|
|
|
public async Task<IActionResult> CliSign(string billingCode, long id)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
2023-03-19 17:57:55 +00:00
|
|
|
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
2026-09-12 12:59:44 +01:00
|
|
|
|
var estimate = dbContext.Estimates
|
|
|
|
|
|
.Include(e => e.Owner)
|
|
|
|
|
|
.Include(e => e.Owner.Performer)
|
|
|
|
|
|
.Include(e => e.Client)
|
|
|
|
|
|
.FirstOrDefault(e => e.Id == id);
|
|
|
|
|
|
if (estimate is null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return NotFound();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (estimate.CommandId is null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var command = dbContext.Set<NominativeServiceCommand>()
|
|
|
|
|
|
.FirstOrDefault(c => c.Id == estimate.CommandId.Value);
|
|
|
|
|
|
if (command is null || command.ClientId != uid)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Request.Form.Files.Count!=1)
|
|
|
|
|
|
return new BadRequestResult();
|
2026-09-17 22:22:18 +01:00
|
|
|
|
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
2026-07-11 03:26:13 +01:00
|
|
|
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
2017-02-23 03:10:30 +01:00
|
|
|
|
dbContext.SaveChanges(User.GetUserId());
|
2026-10-11 00:14:33 +01:00
|
|
|
|
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
|
|
|
|
|
|
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
|
2017-01-13 16:31:40 +01:00
|
|
|
|
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-08 00:26:29 +02:00
|
|
|
|
[HttpGet("clisign/{billingCode}/{id}")]
|
|
|
|
|
|
public async Task<IActionResult> GetCliSign(string billingCode, long id)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
// For authorization purpose
|
|
|
|
|
|
var estimate = dbContext.Estimates.FirstOrDefault(e=>e.Id == id);
|
2025-02-23 20:23:23 +00:00
|
|
|
|
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
|
2017-01-13 16:31:40 +01:00
|
|
|
|
{
|
|
|
|
|
|
return new ChallengeResult();
|
|
|
|
|
|
}
|
2026-07-11 03:26:13 +01:00
|
|
|
|
|
2026-09-22 03:22:01 +01:00
|
|
|
|
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
|
2026-09-17 22:22:18 +01:00
|
|
|
|
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
2026-09-22 03:22:01 +01:00
|
|
|
|
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
|
2026-09-17 22:22:18 +01:00
|
|
|
|
return File(fi.OpenRead(), "application/x-pdf", filename);
|
2017-01-13 16:31:40 +01:00
|
|
|
|
}
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Capture a signature for an estimate, in the JSON
|
|
|
|
|
|
/// wire format produced by PostIt (see
|
|
|
|
|
|
/// <c>PostIt.Models.SignaturePadData</c>). The legacy
|
|
|
|
|
|
/// <c>POST prosign</c> / <c>POST clisign</c> endpoints
|
|
|
|
|
|
/// take a PNG <see cref="IFormFile"/>; this one takes a
|
|
|
|
|
|
/// JSON body so the capture happens entirely in-app on
|
|
|
|
|
|
/// the client side, without a rasterisation step.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// <para>The route is intentionally a sibling of the
|
|
|
|
|
|
/// legacy endpoints, not a replacement: the legacy
|
|
|
|
|
|
/// PNG-based flow stays in place to keep the TeX
|
|
|
|
|
|
/// invoice templates (<c>Bill_tex.cshtml</c>,
|
|
|
|
|
|
/// <c>Estimate_tex.cshtml</c>) working until the
|
|
|
|
|
|
/// migration commit regenerates PNGs from the JSON
|
|
|
|
|
|
/// payload. The two flows share the
|
|
|
|
|
|
/// <see cref="Signature"/> table for storage but not
|
|
|
|
|
|
/// the URL surface.</para>
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[HttpPost("estimate/{id:long}/sign")]
|
2026-07-06 00:57:47 +01:00
|
|
|
|
[ValidateAntiForgeryToken]
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
[Consumes("application/json")]
|
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
|
public async Task<IActionResult> Sign(
|
|
|
|
|
|
[FromRoute] long id,
|
|
|
|
|
|
[FromBody] SignatureSubmission body,
|
|
|
|
|
|
CancellationToken token)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (body is null) return BadRequest(new { Error = "missing body" });
|
|
|
|
|
|
if (body.Strokes is null) return BadRequest(new { Error = "missing strokes" });
|
|
|
|
|
|
if (string.IsNullOrEmpty(body.SignerUserId))
|
|
|
|
|
|
return BadRequest(new { Error = "missing signerUserId" });
|
|
|
|
|
|
|
|
|
|
|
|
var estimate = await dbContext.Estimates
|
|
|
|
|
|
.Include(e => e.Client)
|
|
|
|
|
|
.FirstOrDefaultAsync(e => e.Id == id, token);
|
|
|
|
|
|
if (estimate is null) return NotFound(new { Error = "estimate not found" });
|
|
|
|
|
|
|
|
|
|
|
|
var userId = body.SignerUserId;
|
2026-10-11 00:14:33 +01:00
|
|
|
|
if (userId != User.GetUserId())
|
|
|
|
|
|
return Forbid();
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
|
|
|
|
|
return Forbid();
|
|
|
|
|
|
|
|
|
|
|
|
// Map userId → type. The Pro/Client split is the
|
|
|
|
|
|
// same one the legacy prosign/clisign endpoints use;
|
|
|
|
|
|
// keeping the rule here means the Signature table
|
|
|
|
|
|
// and the legacy ProviderValidationDate/ClientValidationDate
|
|
|
|
|
|
// columns can co-exist without contradicting each other.
|
|
|
|
|
|
var type = userId == estimate.OwnerId
|
|
|
|
|
|
? SignatureType.Pro
|
|
|
|
|
|
: SignatureType.Client;
|
|
|
|
|
|
|
|
|
|
|
|
var payload = new SignaturePadPayload
|
|
|
|
|
|
{
|
|
|
|
|
|
CoordinateMax = body.CoordinateMax,
|
|
|
|
|
|
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
|
|
|
|
|
Strokes = body.Strokes,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.
The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.
Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
|
|
|
|
// The Signature row is staged by the shared persister; we
|
|
|
|
|
|
// SaveChanges here so the signature write is one
|
|
|
|
|
|
// transaction. The Strokes column is the source of truth —
|
|
|
|
|
|
// there is no on-disk copy to roll back on failure.
|
|
|
|
|
|
var signature = await EstimateSignaturePersister.StageAsync(
|
|
|
|
|
|
dbContext, id, type, userId, payload, token);
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
|
2026-10-11 00:14:33 +01:00
|
|
|
|
if (type == SignatureType.Client)
|
|
|
|
|
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
|
|
|
|
|
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
await dbContext.SaveChangesAsync(token);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
|
|
|
|
|
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 00:14:33 +01:00
|
|
|
|
if (type == SignatureType.Client)
|
|
|
|
|
|
{
|
|
|
|
|
|
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
|
|
|
|
|
|
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
var location = Url.Action(nameof(Sign), new { id })
|
|
|
|
|
|
?? $"/api/bill/estimate/{id}/sign";
|
|
|
|
|
|
return Created(location, new
|
|
|
|
|
|
{
|
|
|
|
|
|
id = signature.Id,
|
|
|
|
|
|
estimateId = signature.EstimateId,
|
|
|
|
|
|
type = signature.Type.ToString(),
|
|
|
|
|
|
capturedAtUtc = signature.CapturedAtUtc,
|
|
|
|
|
|
coordinateMax = signature.CoordinateMax,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2016-11-07 19:34:56 +01:00
|
|
|
|
}
|
2018-05-04 10:45:45 +02:00
|
|
|
|
}
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
|
|
|
|
|
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
2026-10-11 00:14:33 +01:00
|
|
|
|
/// field must match the authenticated caller and identifies
|
|
|
|
|
|
/// which party of the estimate signed.
|
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class SignatureSubmission
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// ApplicationUser.Id of the signer. Must equal
|
|
|
|
|
|
/// <c>Estimate.OwnerId</c> for a Pro signature or
|
|
|
|
|
|
/// <c>Estimate.ClientId</c> for a Client signature.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public string SignerUserId { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wire-format strokes. See
|
|
|
|
|
|
/// <c>PostIt.Models.SignaturePadData</c>.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public int[] Strokes { get; set; } = Array.Empty<int>();
|
|
|
|
|
|
|
|
|
|
|
|
public int CoordinateMax { get; set; } = 10_000;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Client-reported capture time. The server may override
|
|
|
|
|
|
/// this with <c>DateTime.UtcNow</c> if the client is
|
|
|
|
|
|
/// caught lying about clock skew, but the default is to
|
|
|
|
|
|
/// trust the client.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public DateTime? CapturedAtUtc { get; set; }
|
|
|
|
|
|
}
|