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:
parent
725fd0c728
commit
064be13388
7 changed files with 177 additions and 7 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue