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.
2026-07-04 15:47:55 +01:00
|
|
|
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
|
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
|
|
|
/// (EstimateId, Type, SignerId) tuple — the controller reads the
|
|
|
|
|
/// most recent when asked. The wire-format payload is the
|
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.
2026-07-04 15:47:55 +01:00
|
|
|
/// 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>
|
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
|
|
|
/// 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.
|
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.
2026-07-04 15:47:55 +01:00
|
|
|
/// </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; }
|
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
|
|
|
}
|