Reopen estimate: carry signature strokes on the estimate payload

Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Schneider 2026-09-21 18:03:08 +01:00
commit 064be13388
7 changed files with 177 additions and 7 deletions

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using PostIt.Controls; using PostIt.Controls;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -132,6 +133,51 @@ public class EstimateValidationPageViewModelTests
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void Load_existing_signature_renders_loaded_strokes_in_pad()
{
var strokes = new[] { 1, 5000, 5000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.Signatures = new List<EstimateSignatureDto>
{
new() { Type = 0, Strokes = strokes, CoordinateMax = 10_000 },
};
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
var pad = new SignaturePadControl();
vm.Attach(pad);
var snap = pad.Snapshot();
Assert.False(snap.IsEmpty);
Assert.Equal(strokes, snap.Strokes);
Assert.True(vm.HasSignature);
}
[Fact]
public void Load_existing_signature_picks_side_by_perspective()
{
var proStrokes = new[] { 1, 1000, 1000 };
var cliStrokes = new[] { 1, 9000, 9000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.Signatures = new List<EstimateSignatureDto>
{
new() { Type = 0, Strokes = proStrokes },
new() { Type = 1, Strokes = cliStrokes },
};
var proPad = new SignaturePadControl();
var proVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
proVm.Attach(proPad);
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
var cliPad = new SignaturePadControl();
var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
cliVm.Attach(cliPad);
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
}
private sealed class RecordingApi : IYavscApiClient private sealed class RecordingApi : IYavscApiClient
{ {
public HttpClient Http { get; } = new(); public HttpClient Http { get; } = new();

View file

@ -181,6 +181,38 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty); RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
/// Loaded strokes are all sealed (there is no pending stroke).
/// Malformed input (a non-positive length prefix, or a stroke that
/// runs past the end of the array) is truncated at the offending
/// point so the buffer never holds an inconsistent state.
/// </summary>
public void Load(int[] strokes)
{
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
_strokes.Add(k);
for (int p = 0; p < 2 * k; p++)
_strokes.Add(strokes[i + 1 + p]);
i += 1 + 2 * k;
}
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Defensive copy of the current buffer wrapped in a /// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the /// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -111,7 +112,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
/// <summary> /// <summary>
/// Bind the page's signature pad control (the inner control of the /// Bind the page's signature pad control (the inner control of the
/// page's <see cref="SignaturePadView"/>). Called from the view's /// page's <see cref="SignaturePadView"/>). Called from the view's
/// code-behind once the view is in the visual tree. /// code-behind once the view is in the visual tree. Also calls
/// <see cref="LoadExistingSignature"/> so a previously drawn
/// signature — carried on the estimate payload — is rendered back
/// into the pad on reopen.
/// </summary> /// </summary>
public void Attach(SignaturePadControl pad) public void Attach(SignaturePadControl pad)
{ {
@ -120,6 +124,7 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
_pad = pad; _pad = pad;
_pad.RedrawRequested += OnPadRedraw; _pad.RedrawRequested += OnPadRedraw;
RefreshFromPad(); RefreshFromPad();
LoadExistingSignature();
} }
public void Detach() public void Detach()
@ -131,6 +136,28 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad(); private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
/// <summary>
/// Render the signature already stored for this side of the
/// estimate back into the pad. The strokes are carried on the
/// estimate payload (<see cref="EstimateDto.Signatures"/>); the
/// side is chosen by the current <see cref="_perspective"/> — the
/// provider reopens to see their "Pro" signature, the client their
/// "Client" signature. No network round-trip: the payload is used
/// as-is, in the same wire format PostIt captured.
/// </summary>
public void LoadExistingSignature()
{
if (_pad is null) return;
// SignatureType.Pro = 0, SignatureType.Client = 1 (server enum).
int wanted = _perspective == EstimateListPerspective.Provider ? 0 : 1;
var sig = _estimate.Signatures?.FirstOrDefault(s => s.Type == wanted);
if (sig?.Strokes is { Length: > 0 } strokes)
{
_pad.Load(strokes);
RefreshFromPad();
}
}
private void RefreshFromPad() private void RefreshFromPad()
{ {
if (_pad is null) return; if (_pad is null) return;

View file

@ -39,6 +39,37 @@ public sealed class EstimateDto
public string CommandType { get; set; } = string.Empty; public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; } public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; } public DateTime ClientValidationDate { get; set; }
/// <summary>
/// Signatures already captured against this estimate (Pro and/or
/// Client), as returned with the estimate payload. Each carries the
/// PostIt wire-format <see cref="EstimateSignatureDto.Strokes"/>
/// so the pad can repaint a previously drawn signature on reopen
/// without a second round-trip. Null/empty when nobody has signed.
/// </summary>
public List<EstimateSignatureDto>? Signatures { get; set; }
}
/// <summary>
/// One stored signature on an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
/// was captured and are loaded back into the pad unchanged.
/// </summary>
public sealed class EstimateSignatureDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
/// <summary>
/// Who signed, as the integer value of the server
/// <c>SignatureType</c> enum: <c>0</c> = Pro (provider),
/// <c>1</c> = Client. Matched by value on the client so PostIt
/// need not reference the server enum.
/// </summary>
public int Type { get; set; }
public int CoordinateMax { get; set; }
public DateTime CapturedAtUtc { get; set; }
public int[]? Strokes { get; set; }
} }
/// <summary> /// <summary>

View file

@ -1,6 +1,8 @@
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures; using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers; using Yavsc.Helpers;
@ -244,4 +246,36 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
[Fact]
public async Task Estimate_payload_carries_existing_signature_strokes()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
// Provider accepts with a signature.
using (var pro = NewClient(subject: "alice"))
{
var r = await pro.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.True(r.IsSuccessStatusCode, await r.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
// The estimate read endpoint carries the signature strokes on
// the estimate payload, so PostIt can repaint them on reopen.
using var reader = NewClient(subject: "alice");
var response = await reader.GetAsync(
$"/api/v1/estimate/{estimateId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var sigs = body.GetProperty("signatures");
Assert.Equal(JsonValueKind.Array, sigs.ValueKind);
var first = sigs.EnumerateArray().First();
// SignatureType.Pro = 0.
Assert.Equal(0, first.GetProperty("type").GetInt32());
Assert.Equal(JsonValueKind.Array, first.GetProperty("strokes").ValueKind);
}
} }

View file

@ -40,7 +40,7 @@ namespace Yavsc.Controllers
else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ; else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ;
// or just do nothing // or just do nothing
return new StatusCodeResult(StatusCodes.Status403Forbidden); return new StatusCodeResult(StatusCodes.Status403Forbidden);
return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId)); return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId));
} }
// GET: api/Estimate/5 // GET: api/Estimate/5
[HttpGet("{id}", Name = "GetEstimate")] [HttpGet("{id}", Name = "GetEstimate")]
@ -51,7 +51,7 @@ namespace Yavsc.Controllers
return BadRequest(ModelState); return BadRequest(ModelState);
} }
Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id); Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id);
if (estimate == null) if (estimate == null)
{ {
@ -209,7 +209,7 @@ namespace Yavsc.Controllers
public IActionResult GetOngoingEstimatesAsClient() public IActionResult GetOngoingEstimatesAsClient()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill) return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.ClientId == uid .Where(e => e.ClientId == uid
&& e.ProviderValidationDate != default && e.ProviderValidationDate != default
&& e.ClientValidationDate == default)); && e.ClientValidationDate == default));
@ -222,7 +222,7 @@ namespace Yavsc.Controllers
public IActionResult GetOngoingEstimatesAsProvider() public IActionResult GetOngoingEstimatesAsProvider()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill) return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default)); .Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
} }

View file

@ -31,7 +31,7 @@ public class Signature
public long EstimateId { get; set; } public long EstimateId { get; set; }
[ForeignKey(nameof(EstimateId)), JsonIgnore] [ForeignKey(nameof(EstimateId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual Estimate Estimate { get; set; } public virtual Estimate Estimate { get; set; }
/// <summary> /// <summary>
@ -48,7 +48,7 @@ public class Signature
[Required] [Required]
public string SignerId { get; set; } public string SignerId { get; set; }
[ForeignKey(nameof(SignerId)), JsonIgnore] [ForeignKey(nameof(SignerId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public virtual ApplicationUser Signer { get; set; } public virtual ApplicationUser Signer { get; set; }
public SignatureType Type { get; set; } public SignatureType Type { get; set; }