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>
77 lines
No EOL
2.8 KiB
C#
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; }
|
|
} |