diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index 25844955..a7047049 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -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); ; } + + /// + /// Capture a signature for an estimate, in the JSON + /// wire format produced by PostIt (see + /// PostIt.Models.SignaturePadData). The legacy + /// POST prosign / POST clisign endpoints + /// take a PNG ; this one takes a + /// JSON body so the capture happens entirely in-app on + /// the client side, without a rasterisation step. + /// + /// 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 (Bill_tex.cshtml, + /// Estimate_tex.cshtml) working until the + /// migration commit regenerates PNGs from the JSON + /// payload. The two flows share the + /// table for storage but not + /// the URL surface. + /// + [HttpPost("estimate/{id:long}/sign")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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, + }); + } } } + +/// +/// JSON body of POST /api/bill/estimate/{id}/sign. The +/// shape mirrors what PostIt sends; the signerUserId +/// field disambiguates which side of the estimate signed +/// because the bearer token belongs to the PostIt OAuth +/// client, not the end user. +/// +public class SignatureSubmission +{ + /// + /// ApplicationUser.Id of the signer. Must equal + /// Estimate.OwnerId for a Pro signature or + /// Estimate.ClientId for a Client signature. + /// + public string SignerUserId { get; set; } + + /// + /// Wire-format strokes. See + /// PostIt.Models.SignaturePadData. + /// + public int[] Strokes { get; set; } = Array.Empty(); + + public int CoordinateMax { get; set; } = 10_000; + + /// + /// Client-reported capture time. The server may override + /// this with DateTime.UtcNow if the client is + /// caught lying about clock skew, but the default is to + /// trust the client. + /// + public DateTime? CapturedAtUtc { get; set; } +} diff --git a/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs new file mode 100644 index 00000000..9de881af --- /dev/null +++ b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs @@ -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; + +/// +/// Tests for the 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. +/// +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(() => + 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(() => + 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")); + } +} diff --git a/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs b/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs new file mode 100644 index 00000000..122b382c --- /dev/null +++ b/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs @@ -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; + +/// +/// Filesystem counterpart of : writes +/// the wire-format JSON payload next to the user's other files, +/// under signatures/, and updates the user's disk quota. +/// +/// This is the JSON counterpart of the legacy +/// ReceiveProSignatureAsync method, which stored +/// sign-{billingCode}-{signType}-{estimateId}.png blobs. +/// We don't reuse that helper because (a) the wire format is no +/// longer a binary image, (b) there's no billingCode on +/// a freshly signed estimate in our model, and (c) the legacy +/// helper takes an whereas our pipeline +/// decodes a JSON body upstream of the controller and passes +/// in directly. +/// +public static class EstimateSignatureFileHelper +{ + /// + /// 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 + /// sign-{type}-{estimateId}-{utcTicks}.json. + /// + public const string SignaturesSubdir = "signatures"; + + /// + /// Format string for signature file names. Public so the + /// migration and the admin tools can list by pattern. + /// + public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks) + => $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json"; + + /// + /// Persist a signature wire payload to disk. Returns the + /// file info (relative path under the user's root) suitable + /// for storing in ; the + /// caller is responsible for the database write. + /// + /// Signed-in user. Their + /// Identity.Name locates the disk root via + /// . + /// + /// Estimate this signature + /// attaches to. Used in the file name for human inspection + /// and to support multiple versions over time. + /// Provider or client signature. + /// Decoded wire payload (strokes + + /// coordinateMax + capturedAtUtc). Already validated + /// upstream. + /// Cancellation token forwarded to + /// the file write. + public static async Task 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; + } +} + +/// +/// Wire payload accepted by the signature endpoint and +/// persisted by . +/// Mirrors PostIt.Models.SignaturePadData's JSON shape +/// (without the disk-only envelope fields) so the two sides +/// stay trivially compatible. +/// +public class SignaturePadPayload +{ + /// + /// Normalised coordinate upper bound. Must be + /// PostIt.Models.SignaturePadData.CoordinateMax + /// (10_000) today; declared as a property so a future + /// resolution change can be replayed against the same + /// wire format. + /// + public int CoordinateMax { get; set; } = 10_000; + + /// + /// Client-reported capture time. The server may ignore + /// this for ordering (UTC now is the truth) but keeps it + /// for round-trip display. + /// + public DateTime CapturedAtUtc { get; set; } + + /// + /// Wire strokes. See + /// PostIt.Models.SignaturePadData for the format. + /// + public int[] Strokes { get; set; } = Array.Empty(); +} diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index 0675fb6a..b8ade1c2 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -69,6 +69,25 @@ namespace Yavsc.Models builder.Entity().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL); builder.Entity().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() + .HasIndex(s => new { s.EstimateId, s.Type, s.CapturedAtUtc }) + .IsDescending(false, false, true); + builder.Entity() + .HasOne(s => s.Estimate) + .WithMany(e => e.Signatures) + .HasForeignKey(s => s.EstimateId) + .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .Property(s => s.CoordinateMax) + .HasDefaultValue(10_000); + builder.Entity().Property(u => u.FullName).IsRequired(false); builder.Entity().Property(u => u.DedicatedGoogleCalendar).IsRequired(false); builder.Entity().HasMany(c => c.Connections); @@ -235,6 +254,7 @@ namespace Yavsc.Models public DbSet Performers { get; set; } public DbSet Estimates { get; set; } + public DbSet Signatures { get; set; } public DbSet BankStatus { get; set; } public DbSet BalanceImpact { get; set; } diff --git a/src/Yavsc.Server/Models/Billing/Estimate.cs b/src/Yavsc.Server/Models/Billing/Estimate.cs index 4473b059..817d22ae 100644 --- a/src/Yavsc.Server/Models/Billing/Estimate.cs +++ b/src/Yavsc.Server/Models/Billing/Estimate.cs @@ -62,20 +62,30 @@ namespace Yavsc.Models.Billing public string OwnerId { get; set; } [ForeignKey("OwnerId"),JsonIgnore] - public virtual PerformerProfile Owner { get; set; } + public virtual PerformerProfile Owner { get; set; } [Required] public string ClientId { get; set; } [ForeignKey("ClientId"),JsonIgnore] - public virtual ApplicationUser Client { get; set; } + public virtual ApplicationUser Client { get; set; } [Required] public string CommandType { get; set; } - public DateTime ProviderValidationDate { get; set; } - public DateTime ClientValidationDate { get; set; } + public DateTime ProviderValidationDate { get; set; } + public DateTime ClientValidationDate { get; set; } + + /// + /// 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 . + /// + [InverseProperty(nameof(Signature.Estimate))] + public virtual ICollection Signatures { get; set; } + = new List(); } } diff --git a/src/Yavsc.Server/Models/Billing/Signature.cs b/src/Yavsc.Server/Models/Billing/Signature.cs new file mode 100644 index 00000000..2e26ae9c --- /dev/null +++ b/src/Yavsc.Server/Models/Billing/Signature.cs @@ -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; + +/// +/// One captured signature, attached to a single +/// . Multiple versions are allowed per +/// (EstimateId, Type, SignerId) tuple — the controller reads +/// the most recent when asked. The wire-format payload is the +/// same int[] shape PostIt produces (see +/// PostIt.Models.SignaturePadData): a length-prefixed +/// sequence of strokes, each stroke being +/// [k, x0, y0, x1, y1, ...] with x, y ∈ [0, +/// CoordinateMax]. +/// +/// +/// Why a separate table (instead of a JSON column on +/// ): 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 row narrow, which matters for +/// list views. +/// +/// +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; } + + /// + /// The ApplicationUser.Id of the signer. Always + /// matches Estimate.OwnerId when + /// is , and + /// Estimate.ClientId when + /// is . + /// 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). + /// + [Required] + public string SignerId { get; set; } + + [ForeignKey(nameof(SignerId)), JsonIgnore] + public virtual ApplicationUser Signer { get; set; } + + public SignatureType Type { get; set; } + + /// + /// The normalised coordinate upper bound used at capture + /// time. Today always + /// PostIt.Models.SignaturePadData.CoordinateMax + /// (10_000). Stored so a future change to the wire format + /// can be replayed against old signatures without data + /// loss. + /// + public int CoordinateMax { get; set; } + + /// + /// Wire-format payload. PostgreSQL stores an int[] + /// natively via Npgsql; the column is round-tripped through + /// JsonConvert only if the migration binds it as + /// text for backwards compatibility (see the EF + /// configuration in ApplicationDbContext). + /// + [Required] + public int[] Strokes { get; set; } = Array.Empty(); + + public DateTime CapturedAtUtc { get; set; } + + /// + /// Path to the JSON-serialised wire payload on disk, + /// relative to UserFilesDirName. The disk copy is the + /// source of truth for the wire bytes; the + /// 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 ApplicationDbContext.SaveChanges. + /// + [Required] + public string FilePath { get; set; } +} diff --git a/src/Yavsc.Server/Models/Billing/SignatureType.cs b/src/Yavsc.Server/Models/Billing/SignatureType.cs new file mode 100644 index 00000000..7c27924f --- /dev/null +++ b/src/Yavsc.Server/Models/Billing/SignatureType.cs @@ -0,0 +1,15 @@ +namespace Yavsc.Models.Billing; + +/// +/// Who signed. is the service provider's +/// signature on a devis or contract; is the +/// customer's signature. A single 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). +/// +public enum SignatureType +{ + Pro = 0, + Client = 1, +}