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