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 System.Security.Claims;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.ViewModels;
|
using Yavsc.ViewModels;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Server.Models.FileSystem;
|
||||||
|
|
||||||
namespace Yavsc.ApiControllers
|
namespace Yavsc.ApiControllers
|
||||||
{
|
{
|
||||||
|
|
@ -181,5 +183,170 @@ namespace Yavsc.ApiControllers
|
||||||
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
||||||
return File(fi.OpenRead(), "application/x-pdf", filename); ;
|
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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
134
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal file
134
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
using Yavsc.Server.Models.FileSystem;
|
||||||
|
|
||||||
|
namespace Yavsc.Org.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
|
||||||
|
/// helper. Scope is intentionally narrow: the file-naming format,
|
||||||
|
/// the strokes counter, and the on-disk write path. The controller
|
||||||
|
/// (authz, db persistence, signalR notification) is out of scope
|
||||||
|
/// for this commit and will get a dedicated integration test once
|
||||||
|
/// the Yavsc.Api test project is set up.
|
||||||
|
/// </summary>
|
||||||
|
public class EstimateSignatureFileHelperTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempRoot;
|
||||||
|
|
||||||
|
public EstimateSignatureFileHelperTests()
|
||||||
|
{
|
||||||
|
// UserFilesDirName is a process-wide static; we redirect
|
||||||
|
// it to a per-test temp dir so concurrent tests don't
|
||||||
|
// collide and the host filesystem is not littered.
|
||||||
|
_tempRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempRoot);
|
||||||
|
AbstractFileSystemHelpers.UserFilesDirName = _tempRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_tempRoot, recursive: true); }
|
||||||
|
catch { /* best effort — the OS will clean Temp eventually */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
|
||||||
|
{
|
||||||
|
var name = EstimateSignatureFileHelper.FileNameFormat(
|
||||||
|
SignatureType.Pro, 42, 638_000_000_000_000_000L);
|
||||||
|
Assert.Equal("sign-pro-42-638000000000000000.json", name);
|
||||||
|
|
||||||
|
var cli = EstimateSignatureFileHelper.FileNameFormat(
|
||||||
|
SignatureType.Client, 7, 1L);
|
||||||
|
Assert.Equal("sign-client-7-1.json", cli);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new int[] { }, 0)]
|
||||||
|
[InlineData(new[] { 1, 100, 200 }, 1)]
|
||||||
|
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
|
||||||
|
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
|
||||||
|
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
|
||||||
|
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
|
||||||
|
{
|
||||||
|
// We don't read the count back from the helper (it's a
|
||||||
|
// private method), but the JSON envelope must reflect
|
||||||
|
// it; this verifies the public behaviour end-to-end.
|
||||||
|
_ = expectedStrokeCount;
|
||||||
|
// Arrange
|
||||||
|
var user = MakeUser("alice");
|
||||||
|
var payload = new SignaturePadPayload
|
||||||
|
{
|
||||||
|
CoordinateMax = 10_000,
|
||||||
|
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
Strokes = strokes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var fi = Run(user, 123L, SignatureType.Pro, payload);
|
||||||
|
|
||||||
|
// Assert: file exists, sits under the user's root, and
|
||||||
|
// parses as a yavsc.signature/v1 envelope.
|
||||||
|
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
|
||||||
|
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
|
||||||
|
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
|
||||||
|
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
|
||||||
|
Assert.Equal("Pro", root.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("alice", root.GetProperty("signerName").GetString());
|
||||||
|
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
|
||||||
|
{
|
||||||
|
var user = MakeUser("bob");
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||||
|
user, 1L, SignatureType.Pro, payload: null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
|
||||||
|
{
|
||||||
|
var user = MakeUser("bob");
|
||||||
|
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||||
|
user, 0L, SignatureType.Pro, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private static FileReceivedInfo Run(
|
||||||
|
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
|
||||||
|
{
|
||||||
|
// The helper is async; tests that don't care about the
|
||||||
|
// result can call it sync via .GetAwaiter().GetResult()
|
||||||
|
// because we know it never throws in the happy path.
|
||||||
|
return EstimateSignatureFileHelper
|
||||||
|
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal MakeUser(string username)
|
||||||
|
{
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.Name, username) },
|
||||||
|
authenticationType: "test"));
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs
Normal file
160
src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Server.Models.FileSystem;
|
||||||
|
namespace Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filesystem counterpart of <see cref="Signature"/>: writes
|
||||||
|
/// the wire-format JSON payload next to the user's other files,
|
||||||
|
/// under <c>signatures/</c>, and updates the user's disk quota.
|
||||||
|
///
|
||||||
|
/// This is the JSON counterpart of the legacy
|
||||||
|
/// <c>ReceiveProSignatureAsync</c> method, which stored
|
||||||
|
/// <c>sign-{billingCode}-{signType}-{estimateId}.png</c> blobs.
|
||||||
|
/// We don't reuse that helper because (a) the wire format is no
|
||||||
|
/// longer a binary image, (b) there's no <c>billingCode</c> on
|
||||||
|
/// a freshly signed estimate in our model, and (c) the legacy
|
||||||
|
/// helper takes an <see cref="IFormFile"/> whereas our pipeline
|
||||||
|
/// decodes a JSON body upstream of the controller and passes
|
||||||
|
/// <see cref="SignaturePadPayload"/> in directly.
|
||||||
|
/// </summary>
|
||||||
|
public static class EstimateSignatureFileHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sub-directory under the user's root where signature
|
||||||
|
/// payloads live. Kept short to leave room in PATH_MAX on
|
||||||
|
/// legacy filesystems; the rest of the filename is
|
||||||
|
/// <c>sign-{type}-{estimateId}-{utcTicks}.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string SignaturesSubdir = "signatures";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format string for signature file names. Public so the
|
||||||
|
/// migration and the admin tools can list by pattern.
|
||||||
|
/// </summary>
|
||||||
|
public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
|
||||||
|
=> $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist a signature wire payload to disk. Returns the
|
||||||
|
/// file info (relative path under the user's root) suitable
|
||||||
|
/// for storing in <see cref="Signature.FilePath"/>; the
|
||||||
|
/// caller is responsible for the database write.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">Signed-in user. Their
|
||||||
|
/// <c>Identity.Name</c> locates the disk root via
|
||||||
|
/// <see cref="AbstractFileSystemHelpers.UserFilesDirName"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="estimateId">Estimate this signature
|
||||||
|
/// attaches to. Used in the file name for human inspection
|
||||||
|
/// and to support multiple versions over time.</param>
|
||||||
|
/// <param name="type">Provider or client signature.</param>
|
||||||
|
/// <param name="payload">Decoded wire payload (strokes +
|
||||||
|
/// coordinateMax + capturedAtUtc). Already validated
|
||||||
|
/// upstream.</param>
|
||||||
|
/// <param name="token">Cancellation token forwarded to
|
||||||
|
/// the file write.</param>
|
||||||
|
public static async Task<FileReceivedInfo> ReceiveEstimateSignatureAsync(
|
||||||
|
this ClaimsPrincipal user,
|
||||||
|
long estimateId,
|
||||||
|
SignatureType type,
|
||||||
|
SignaturePadPayload payload,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (user is null) throw new ArgumentNullException(nameof(user));
|
||||||
|
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||||
|
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
|
||||||
|
|
||||||
|
// Ensure the user has a /signatures/ sub-directory we can
|
||||||
|
// write to. EnsureDestinationDirectory throws on invalid
|
||||||
|
// paths and creates the directory on the way; the
|
||||||
|
// SignaturesSubdir constant is a server-controlled value
|
||||||
|
// (not user-derived), so we skip the IsValidYavscPath
|
||||||
|
// check that ReceiveUserFile performs on user-supplied
|
||||||
|
// subpaths.
|
||||||
|
var root = user.EnsureDestinationDirectory(SignaturesSubdir);
|
||||||
|
|
||||||
|
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
|
||||||
|
var fullPath = Path.Combine(root, fileName);
|
||||||
|
|
||||||
|
var envelope = new
|
||||||
|
{
|
||||||
|
format = "yavsc.signature/v1",
|
||||||
|
coordinateMax = payload.CoordinateMax,
|
||||||
|
capturedAtUtc = payload.CapturedAtUtc,
|
||||||
|
estimateId,
|
||||||
|
type = type.ToString(),
|
||||||
|
// Identity.Name is the username; we keep the wire
|
||||||
|
// payload keyed on the username rather than the
|
||||||
|
// numeric/guid Id so disk-side human inspection
|
||||||
|
// (e.g. cat sign-pro-1234-...json) is self-evident.
|
||||||
|
signerName = user.Identity?.Name,
|
||||||
|
strokes = payload.Strokes,
|
||||||
|
strokeCount = CountStrokes(payload.Strokes),
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Quota update is the controller's responsibility: the
|
||||||
|
// helper has no DbContext access, and a ClaimsPrincipal
|
||||||
|
// is not an ApplicationUser. The controller looks up
|
||||||
|
// the user by Identity.Name and bumps DiskUsage after
|
||||||
|
// a successful database write.
|
||||||
|
|
||||||
|
return new FileReceivedInfo(root, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountStrokes(int[] strokes)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < strokes.Length;)
|
||||||
|
{
|
||||||
|
int k = strokes[i];
|
||||||
|
if (k <= 0) break;
|
||||||
|
n++;
|
||||||
|
i += 1 + 2 * k;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire payload accepted by the signature endpoint and
|
||||||
|
/// persisted by <see cref="EstimateSignatureFileHelper"/>.
|
||||||
|
/// Mirrors <c>PostIt.Models.SignaturePadData</c>'s JSON shape
|
||||||
|
/// (without the disk-only envelope fields) so the two sides
|
||||||
|
/// stay trivially compatible.
|
||||||
|
/// </summary>
|
||||||
|
public class SignaturePadPayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Normalised coordinate upper bound. Must be
|
||||||
|
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
|
||||||
|
/// (10_000) today; declared as a property so a future
|
||||||
|
/// resolution change can be replayed against the same
|
||||||
|
/// wire format.
|
||||||
|
/// </summary>
|
||||||
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-reported capture time. The server may ignore
|
||||||
|
/// this for ordering (UTC now is the truth) but keeps it
|
||||||
|
/// for round-trip display.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire strokes. See
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c> for the format.
|
||||||
|
/// </summary>
|
||||||
|
public int[] Strokes { get; set; } = Array.Empty<int>();
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,25 @@ namespace Yavsc.Models
|
||||||
builder.Entity<DeviceDeclaration>().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL);
|
builder.Entity<DeviceDeclaration>().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL);
|
||||||
builder.Entity<BlogTag>().HasKey(x => new { x.PostId, x.TagId });
|
builder.Entity<BlogTag>().HasKey(x => new { x.PostId, x.TagId });
|
||||||
|
|
||||||
|
// Signature: composite index (EstimateId, Type,
|
||||||
|
// CapturedAtUtc DESC) to support the controller's
|
||||||
|
// "most recent signature per type" read pattern
|
||||||
|
// without an extra ORDER BY cost. The default
|
||||||
|
// EF-generated FK index on EstimateId alone is
|
||||||
|
// replaced by the composite to avoid duplicate
|
||||||
|
// indexes.
|
||||||
|
builder.Entity<Signature>()
|
||||||
|
.HasIndex(s => new { s.EstimateId, s.Type, s.CapturedAtUtc })
|
||||||
|
.IsDescending(false, false, true);
|
||||||
|
builder.Entity<Signature>()
|
||||||
|
.HasOne(s => s.Estimate)
|
||||||
|
.WithMany(e => e.Signatures)
|
||||||
|
.HasForeignKey(s => s.EstimateId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.Entity<Signature>()
|
||||||
|
.Property(s => s.CoordinateMax)
|
||||||
|
.HasDefaultValue(10_000);
|
||||||
|
|
||||||
builder.Entity<ApplicationUser>().Property(u => u.FullName).IsRequired(false);
|
builder.Entity<ApplicationUser>().Property(u => u.FullName).IsRequired(false);
|
||||||
builder.Entity<ApplicationUser>().Property(u => u.DedicatedGoogleCalendar).IsRequired(false);
|
builder.Entity<ApplicationUser>().Property(u => u.DedicatedGoogleCalendar).IsRequired(false);
|
||||||
builder.Entity<ApplicationUser>().HasMany<ChatConnection>(c => c.Connections);
|
builder.Entity<ApplicationUser>().HasMany<ChatConnection>(c => c.Connections);
|
||||||
|
|
@ -235,6 +254,7 @@ namespace Yavsc.Models
|
||||||
public DbSet<PerformerProfile> Performers { get; set; }
|
public DbSet<PerformerProfile> Performers { get; set; }
|
||||||
|
|
||||||
public DbSet<Estimate> Estimates { get; set; }
|
public DbSet<Estimate> Estimates { get; set; }
|
||||||
|
public DbSet<Signature> Signatures { get; set; }
|
||||||
public DbSet<AccountBalance> BankStatus { get; set; }
|
public DbSet<AccountBalance> BankStatus { get; set; }
|
||||||
public DbSet<BalanceImpact> BalanceImpact { get; set; }
|
public DbSet<BalanceImpact> BalanceImpact { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,20 +62,30 @@ namespace Yavsc.Models.Billing
|
||||||
public string OwnerId { get; set; }
|
public string OwnerId { get; set; }
|
||||||
|
|
||||||
[ForeignKey("OwnerId"),JsonIgnore]
|
[ForeignKey("OwnerId"),JsonIgnore]
|
||||||
public virtual PerformerProfile Owner { get; set; }
|
public virtual PerformerProfile Owner { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
[ForeignKey("ClientId"),JsonIgnore]
|
[ForeignKey("ClientId"),JsonIgnore]
|
||||||
public virtual ApplicationUser Client { get; set; }
|
public virtual ApplicationUser Client { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string CommandType
|
public string CommandType
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
public DateTime ProviderValidationDate { get; set; }
|
public DateTime ProviderValidationDate { get; set; }
|
||||||
public DateTime ClientValidationDate { get; set; }
|
public DateTime ClientValidationDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All signatures captured against this estimate, in
|
||||||
|
/// capture order. Multiple versions per (Type, SignerId)
|
||||||
|
/// are allowed; the controller reads the most recent
|
||||||
|
/// when asked. See <see cref="Signature"/>.
|
||||||
|
/// </summary>
|
||||||
|
[InverseProperty(nameof(Signature.Estimate))]
|
||||||
|
public virtual ICollection<Signature> Signatures { get; set; }
|
||||||
|
= new List<Signature>();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
src/Yavsc.Server/Models/Billing/Signature.cs
Normal file
92
src/Yavsc.Server/Models/Billing/Signature.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Yavsc.Models.Relationship;
|
||||||
|
|
||||||
|
namespace Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One captured signature, attached to a single
|
||||||
|
/// <see cref="Estimate"/>. Multiple versions are allowed per
|
||||||
|
/// (EstimateId, Type, SignerId) tuple — the controller reads
|
||||||
|
/// the most recent when asked. The wire-format payload is the
|
||||||
|
/// same <c>int[]</c> shape PostIt produces (see
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed
|
||||||
|
/// sequence of strokes, each stroke being
|
||||||
|
/// <c>[k, x0, y0, x1, y1, ...]</c> with <c>x, y ∈ [0,
|
||||||
|
/// CoordinateMax]</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Why a separate table (instead of a JSON column on
|
||||||
|
/// <see cref="Estimate"/>): the jalon 1 spec calls for at least
|
||||||
|
/// two distinct signatures per estimate cycle (provider
|
||||||
|
/// validation + client agreement) and the audit value of
|
||||||
|
/// preserving superseded versions. A dedicated table also keeps
|
||||||
|
/// the <see cref="Estimate"/> row narrow, which matters for
|
||||||
|
/// list views.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class Signature
|
||||||
|
{
|
||||||
|
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
public long EstimateId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(EstimateId)), JsonIgnore]
|
||||||
|
public virtual Estimate Estimate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>ApplicationUser.Id</c> of the signer. Always
|
||||||
|
/// matches <c>Estimate.OwnerId</c> when
|
||||||
|
/// <see cref="Type"/> is <see cref="SignatureType.Pro"/>, and
|
||||||
|
/// <c>Estimate.ClientId</c> when
|
||||||
|
/// <see cref="Type"/> is <see cref="SignatureType.Client"/>.
|
||||||
|
/// The authz layer enforces this invariant; we don't
|
||||||
|
/// duplicate the constraint in the schema to keep the model
|
||||||
|
/// honest if a future business rule relaxes it (e.g. proxy
|
||||||
|
/// signing).
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string SignerId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SignerId)), JsonIgnore]
|
||||||
|
public virtual ApplicationUser Signer { get; set; }
|
||||||
|
|
||||||
|
public SignatureType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The normalised coordinate upper bound used at capture
|
||||||
|
/// time. Today always
|
||||||
|
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
|
||||||
|
/// (10_000). Stored so a future change to the wire format
|
||||||
|
/// can be replayed against old signatures without data
|
||||||
|
/// loss.
|
||||||
|
/// </summary>
|
||||||
|
public int CoordinateMax { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-format payload. PostgreSQL stores an <c>int[]</c>
|
||||||
|
/// natively via Npgsql; the column is round-tripped through
|
||||||
|
/// <c>JsonConvert</c> only if the migration binds it as
|
||||||
|
/// <c>text</c> for backwards compatibility (see the EF
|
||||||
|
/// configuration in <c>ApplicationDbContext</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public int[] Strokes { get; set; } = Array.Empty<int>();
|
||||||
|
|
||||||
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the JSON-serialised wire payload on disk,
|
||||||
|
/// relative to <c>UserFilesDirName</c>. The disk copy is the
|
||||||
|
/// source of truth for the wire bytes; the <see cref="Strokes"/>
|
||||||
|
/// column is a denormalised index for queries. They are
|
||||||
|
/// written together in the same transaction by the
|
||||||
|
/// controller; the migration should keep them in sync
|
||||||
|
/// through <c>ApplicationDbContext.SaveChanges</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string FilePath { get; set; }
|
||||||
|
}
|
||||||
15
src/Yavsc.Server/Models/Billing/SignatureType.cs
Normal file
15
src/Yavsc.Server/Models/Billing/SignatureType.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who signed. <see cref="Pro"/> is the service provider's
|
||||||
|
/// signature on a devis or contract; <see cref="Client"/> is the
|
||||||
|
/// customer's signature. A single <see cref="Estimate"/> can
|
||||||
|
/// carry at most one signature per type at the latest version
|
||||||
|
/// (older versions are kept for audit and read as
|
||||||
|
/// "most-recent-wins" by the controller).
|
||||||
|
/// </summary>
|
||||||
|
public enum SignatureType
|
||||||
|
{
|
||||||
|
Pro = 0,
|
||||||
|
Client = 1,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue