yavsc/src/Yavsc.Server/Models/Billing/Signature.cs
Paul Schneider 709846c58d
Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00

77 lines
No EOL
2.8 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
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>
/// The <see cref="Strokes"/> column is the source of truth: the
/// signature is captured and stored entirely in-app (PostIt) and
/// round-tripped as JSON, with no on-disk copy. A dedicated table
/// (instead of a JSON column on <see cref="Estimate"/>) keeps the
/// <see cref="Estimate"/> row narrow for list views and preserves
/// superseded versions for audit.
/// </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; }
}