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.
This commit is contained in:
parent
1d26cbdf3d
commit
f4eb14d083
7 changed files with 602 additions and 4 deletions
|
|
@ -5,6 +5,8 @@ using Newtonsoft.Json;
|
|||
using System.Security.Claims;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.ViewModels;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
|
||||
namespace Yavsc.ApiControllers
|
||||
{
|
||||
|
|
@ -181,5 +183,170 @@ namespace Yavsc.ApiControllers
|
|||
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
||||
return File(fi.OpenRead(), "application/x-pdf", filename); ;
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
[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" });
|
||||
|
||||
// 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 != 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,
|
||||
};
|
||||
|
||||
// Disk write first: a disk failure shouldn't leave
|
||||
// a Signature row pointing at a file that doesn't
|
||||
// exist. The file helper throws on filesystem
|
||||
// problems and propagates here.
|
||||
FileReceivedInfo fi;
|
||||
try
|
||||
{
|
||||
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
|
||||
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
|
||||
}
|
||||
|
||||
// Now persist the database row. The int[] is round-
|
||||
// tripped via Npgsql's native int[] mapping; the
|
||||
// migration (separate commit) introduces the column
|
||||
// and the index.
|
||||
var signature = new Signature
|
||||
{
|
||||
EstimateId = id,
|
||||
SignerId = userId,
|
||||
Type = type,
|
||||
CoordinateMax = payload.CoordinateMax,
|
||||
Strokes = payload.Strokes,
|
||||
CapturedAtUtc = payload.CapturedAtUtc,
|
||||
FilePath = Path.Combine(fi.DestDir, fi.FileName),
|
||||
};
|
||||
dbContext.Signatures.Add(signature);
|
||||
|
||||
// Bump the signer's quota. The Signature row's
|
||||
// SignerId is the IdentityUser.Id (a string), so we
|
||||
// look up by Id and not by username.
|
||||
var signer = await dbContext.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == userId, token);
|
||||
if (signer is not null)
|
||||
{
|
||||
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
||||
// Best-effort rollback: remove the file we wrote
|
||||
// so disk and db don't disagree.
|
||||
try { System.IO.File.Delete(signature.FilePath); }
|
||||
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
|
||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue