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:
Lum 2026-07-04 15:47:55 +01:00
commit f4eb14d083
7 changed files with 602 additions and 4 deletions

View file

@ -69,6 +69,25 @@ namespace Yavsc.Models
builder.Entity<DeviceDeclaration>().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL);
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.DedicatedGoogleCalendar).IsRequired(false);
builder.Entity<ApplicationUser>().HasMany<ChatConnection>(c => c.Connections);
@ -235,6 +254,7 @@ namespace Yavsc.Models
public DbSet<PerformerProfile> Performers { get; set; }
public DbSet<Estimate> Estimates { get; set; }
public DbSet<Signature> Signatures { get; set; }
public DbSet<AccountBalance> BankStatus { get; set; }
public DbSet<BalanceImpact> BalanceImpact { get; set; }

View file

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

View 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; }
}

View 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,
}